Compare commits
2 Commits
dev
...
237-provid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62cc3a5f1f | ||
|
|
079a7dcaf3 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -252,10 +252,6 @@ ## Active Technologies
|
||||
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
|
||||
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
|
||||
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
||||
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -290,9 +286,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
||||
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
|
||||
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
398
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
398
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
@ -1,398 +0,0 @@
|
||||
---
|
||||
name: spec-kit-next-best-one-shot
|
||||
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code.
|
||||
---
|
||||
|
||||
# Skill: Spec Kit Next-Best One-Shot Preparation
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass:
|
||||
|
||||
1. select the next best spec candidate from roadmap and spec candidates
|
||||
2. run the repository's Spec Kit `specify` flow for that selected candidate
|
||||
3. run the repository's Spec Kit `plan` flow for the generated spec
|
||||
4. run the repository's Spec Kit `tasks` flow for the generated plan
|
||||
5. run the repository's Spec Kit `analyze` flow against the generated artifacts
|
||||
6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required)
|
||||
7. stop before implementation
|
||||
8. provide a concise readiness summary for the user
|
||||
|
||||
This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
```text
|
||||
roadmap.md + spec-candidates.md
|
||||
→ select next best spec
|
||||
→ run Spec Kit specify
|
||||
→ run Spec Kit plan
|
||||
→ run Spec Kit tasks
|
||||
→ run Spec Kit analyze
|
||||
→ fix preparation-artifact issues
|
||||
→ explicit implementation step later
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the user asks things like:
|
||||
|
||||
```text
|
||||
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus.
|
||||
```
|
||||
|
||||
```text
|
||||
Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch.
|
||||
```
|
||||
|
||||
```text
|
||||
Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze.
|
||||
```
|
||||
|
||||
```text
|
||||
Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor.
|
||||
```
|
||||
|
||||
```text
|
||||
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren.
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Work strictly repo-based.
|
||||
- Use the repository's actual GitHub Spec Kit workflow.
|
||||
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
|
||||
- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them.
|
||||
- Do not bypass Spec Kit branch mechanics.
|
||||
- Run `analyze` after `tasks` when the repository supports it.
|
||||
- Fix only issues found in Spec Kit preparation artifacts and planning metadata.
|
||||
- Do not treat analyze findings as permission to implement product code.
|
||||
- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it.
|
||||
- Do not implement application code.
|
||||
- Do not modify production code.
|
||||
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
||||
- Do not execute implementation commands.
|
||||
- Do not run destructive commands.
|
||||
- Do not invent roadmap priorities not supported by repository documents.
|
||||
- Do not pick a spec only because it is listed first.
|
||||
- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate.
|
||||
- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||
- Prefer small, reviewable, implementation-ready specs over large ambiguous themes.
|
||||
- Preserve TenantPilot/TenantAtlas terminology.
|
||||
- Follow the repository constitution and existing Spec Kit conventions.
|
||||
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
|
||||
- If no candidate is suitable, do not run Spec Kit commands and explain why.
|
||||
|
||||
## Required Repository Checks Before Selection
|
||||
|
||||
Before selecting the next spec, inspect:
|
||||
|
||||
1. `.specify/memory/constitution.md`
|
||||
2. `.specify/templates/`
|
||||
3. `.specify/scripts/`
|
||||
4. existing Spec Kit command usage or repository instructions, if present
|
||||
5. `specs/`
|
||||
6. `docs/product/spec-candidates.md`
|
||||
7. roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
||||
8. nearby existing specs related to top candidate areas
|
||||
9. current branch and git status
|
||||
10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
|
||||
|
||||
Do not edit application code.
|
||||
|
||||
## Git and Branch Safety
|
||||
|
||||
Before running any Spec Kit command or script:
|
||||
|
||||
1. Check the current branch.
|
||||
2. Check whether the working tree is clean.
|
||||
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
|
||||
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
|
||||
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
|
||||
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
7. Do not overwrite existing specs.
|
||||
|
||||
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
|
||||
|
||||
## Candidate Selection Criteria
|
||||
|
||||
Evaluate candidate specs using these criteria.
|
||||
|
||||
### 1. Roadmap Fit
|
||||
|
||||
Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer.
|
||||
|
||||
Examples:
|
||||
|
||||
- governance foundations before advanced compliance views
|
||||
- evidence/snapshot foundations before auditor packs
|
||||
- control catalog foundations before CIS/NIS2 mappings
|
||||
- decision/workflow surfaces before autonomous governance
|
||||
- provider/platform boundary cleanup before multi-provider expansion
|
||||
|
||||
### 2. Foundation Value
|
||||
|
||||
Prefer candidates that strengthen reusable platform foundations:
|
||||
|
||||
- RBAC and workspace/tenant isolation
|
||||
- auditability
|
||||
- evidence and snapshot truth
|
||||
- operation observability
|
||||
- provider boundary neutrality
|
||||
- canonical vocabulary
|
||||
- baseline/control/finding semantics
|
||||
- enterprise detail-page or decision-surface patterns
|
||||
|
||||
### 3. Dependency Unblocking
|
||||
|
||||
Prefer specs that unblock multiple later candidates.
|
||||
|
||||
A good next spec should usually make future specs smaller, safer, or more consistent.
|
||||
|
||||
### 4. Scope Size
|
||||
|
||||
Prefer a candidate that can be implemented as a narrow, testable slice.
|
||||
|
||||
Avoid selecting:
|
||||
|
||||
- broad platform rewrites
|
||||
- vague product themes
|
||||
- multi-feature bundles
|
||||
- speculative future-provider frameworks
|
||||
- large UX redesigns without a clear first slice
|
||||
|
||||
### 5. Repo Readiness
|
||||
|
||||
Prefer candidates where the repository already has enough structure to implement the next slice safely.
|
||||
|
||||
Check whether related models, services, UI pages, tests, or concepts already exist.
|
||||
|
||||
### 6. Risk Reduction
|
||||
|
||||
Prefer candidates that reduce current architectural or product risk:
|
||||
|
||||
- legacy dual-world semantics
|
||||
- unclear truth ownership
|
||||
- inconsistent operator UX
|
||||
- missing audit/evidence boundaries
|
||||
- repeated manual workflow friction
|
||||
- false-positive calmness in governance surfaces
|
||||
|
||||
### 7. User/Product Value
|
||||
|
||||
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
|
||||
|
||||
## Required Selection Output Before Spec Kit Execution
|
||||
|
||||
Before running the Spec Kit flow, identify:
|
||||
|
||||
- selected candidate title
|
||||
- source location in roadmap/spec-candidates
|
||||
- why it was selected
|
||||
- why close alternatives were deferred
|
||||
- roadmap relationship
|
||||
- smallest viable implementation slice
|
||||
- proposed concise feature description to feed into `specify`
|
||||
|
||||
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
|
||||
|
||||
## Spec Kit Execution Flow
|
||||
|
||||
After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes.
|
||||
|
||||
### Step 1: Determine the repository's Spec Kit command pattern
|
||||
|
||||
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
|
||||
|
||||
Common locations to inspect:
|
||||
|
||||
```text
|
||||
.specify/scripts/
|
||||
.specify/templates/
|
||||
.specify/memory/constitution.md
|
||||
.github/prompts/
|
||||
.github/skills/
|
||||
README.md
|
||||
specs/
|
||||
```
|
||||
|
||||
Use the repo-specific mechanism if present.
|
||||
|
||||
### Step 2: Run `specify`
|
||||
|
||||
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
|
||||
|
||||
The `specify` input should include:
|
||||
|
||||
- selected candidate title
|
||||
- problem statement
|
||||
- operator/user value
|
||||
- roadmap relationship
|
||||
- out-of-scope boundaries
|
||||
- key acceptance criteria
|
||||
- important enterprise constraints
|
||||
|
||||
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
|
||||
|
||||
### Step 3: Run `plan`
|
||||
|
||||
Run the repository's `plan` flow for the generated spec.
|
||||
|
||||
The `plan` input should keep the scope tight and should require repo-based alignment with:
|
||||
|
||||
- constitution
|
||||
- existing architecture
|
||||
- workspace/tenant isolation
|
||||
- RBAC
|
||||
- OperationRun/observability where relevant
|
||||
- evidence/snapshot/truth semantics where relevant
|
||||
- Filament/Livewire conventions where relevant
|
||||
- test strategy
|
||||
|
||||
### Step 4: Run `tasks`
|
||||
|
||||
Run the repository's `tasks` flow for the generated plan.
|
||||
|
||||
The generated tasks must be:
|
||||
|
||||
- ordered
|
||||
- small
|
||||
- testable
|
||||
- grouped by phase
|
||||
- limited to the selected scope
|
||||
- suitable for later manual analysis before implementation
|
||||
|
||||
### Step 5: Run `analyze`
|
||||
|
||||
Run the repository's `analyze` flow against the generated Spec Kit artifacts.
|
||||
|
||||
Analyze must check:
|
||||
|
||||
- consistency between `spec.md`, `plan.md`, and `tasks.md`
|
||||
- constitution alignment
|
||||
- roadmap alignment
|
||||
- whether the selected candidate was narrowed safely
|
||||
- whether tasks are complete enough for implementation
|
||||
- whether tasks accidentally require scope not described in the spec
|
||||
- whether plan details conflict with repository architecture or terminology
|
||||
- whether implementation risks are documented instead of silently ignored
|
||||
|
||||
Do not use analyze as a trigger to implement application code.
|
||||
|
||||
### Step 6: Fix preparation-artifact issues only
|
||||
|
||||
If analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
||||
|
||||
- `spec.md`
|
||||
- `plan.md`
|
||||
- `tasks.md`
|
||||
- generated Spec Kit metadata files, if the repository uses them
|
||||
|
||||
Allowed fixes include:
|
||||
|
||||
- clarify requirements
|
||||
- tighten scope
|
||||
- move out-of-scope work into follow-up candidates
|
||||
- correct terminology
|
||||
- add missing tasks
|
||||
- remove tasks not backed by the spec
|
||||
- align plan language with repository architecture
|
||||
- add missing acceptance criteria or validation tasks
|
||||
|
||||
Forbidden fixes include:
|
||||
|
||||
- modifying application code
|
||||
- creating migrations
|
||||
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands
|
||||
- running implementation or test-fix loops
|
||||
- changing runtime behavior
|
||||
|
||||
### Step 7: Stop
|
||||
|
||||
After `analyze` has passed or preparation-artifact issues have been fixed, stop.
|
||||
|
||||
Do not implement.
|
||||
Do not modify application code.
|
||||
Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If a Spec Kit command or analyze phase fails:
|
||||
|
||||
1. Stop immediately.
|
||||
2. Report the failing command or phase.
|
||||
3. Summarize the error.
|
||||
4. Do not attempt implementation as a workaround.
|
||||
5. Suggest the smallest safe next action.
|
||||
|
||||
If the branch or working tree state is unsafe:
|
||||
|
||||
1. Stop before running Spec Kit commands.
|
||||
2. Report the current branch and relevant uncommitted files.
|
||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||
|
||||
## Final Response Requirements
|
||||
|
||||
After the Spec Kit preparation flow completes, respond with:
|
||||
|
||||
1. Selected candidate
|
||||
2. Why this candidate was selected
|
||||
3. Why close alternatives were deferred
|
||||
4. Current branch after Spec Kit execution
|
||||
5. Generated spec path
|
||||
6. Files created or updated by Spec Kit
|
||||
7. Analyze result summary
|
||||
8. Preparation-artifact fixes applied after analyze
|
||||
9. Assumptions made
|
||||
10. Open questions, if any
|
||||
11. Recommended next implementation prompt
|
||||
12. Explicit statement that no application implementation was performed
|
||||
|
||||
Keep the response concise, but include enough detail for the user to continue immediately.
|
||||
|
||||
## Required Next Implementation Prompt
|
||||
|
||||
Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed:
|
||||
|
||||
```markdown
|
||||
Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas.
|
||||
|
||||
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
|
||||
|
||||
Wichtig:
|
||||
- Arbeite task-sequenziell.
|
||||
- Ändere nur Dateien, die für die jeweilige Task notwendig sind.
|
||||
- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution.
|
||||
- Keine Scope-Erweiterung.
|
||||
- Keine Opportunistic Refactors.
|
||||
- Führe passende Tests nach sinnvollen Task-Gruppen aus.
|
||||
- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren.
|
||||
- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks.
|
||||
```
|
||||
|
||||
## Example Invocation
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Nutze den Skill spec-kit-next-best-one-shot.
|
||||
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
|
||||
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
|
||||
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
|
||||
Keine Application-Implementierung.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
|
||||
2. Check branch and working tree safety.
|
||||
3. Compare candidate suitability.
|
||||
4. Select the next best candidate.
|
||||
5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||
6. Run the repository's real Spec Kit `plan` flow.
|
||||
7. Run the repository's real Spec Kit `tasks` flow.
|
||||
8. Run the repository's real Spec Kit `analyze` flow.
|
||||
9. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||
10. Stop before application implementation.
|
||||
11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt.
|
||||
```
|
||||
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
@ -1,294 +0,0 @@
|
||||
---
|
||||
name: spec-kit-one-shot-prep
|
||||
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||
---
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
Define the functionality provided by this skill, including detailed instructions and examples
|
||||
---
|
||||
name: spec-kit-one-shot-prep
|
||||
description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code.
|
||||
---
|
||||
|
||||
# Skill: Spec Kit One-Shot Preparation
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass:
|
||||
|
||||
1. `spec.md`
|
||||
2. `plan.md`
|
||||
3. `tasks.md`
|
||||
|
||||
This skill prepares implementation work, but it must not perform implementation.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
```text
|
||||
feature idea / roadmap item / spec candidate
|
||||
→ one-shot spec + plan + tasks preparation
|
||||
→ manual repo-based analysis/review
|
||||
→ explicit implementation step later
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the user asks to create or prepare Spec Kit artifacts from:
|
||||
|
||||
- a feature idea
|
||||
- a spec candidate
|
||||
- a roadmap item
|
||||
- a product or UX requirement
|
||||
- a governance/platform improvement
|
||||
- an architecture cleanup candidate
|
||||
- a refactoring preparation request
|
||||
- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec
|
||||
|
||||
Typical user prompts:
|
||||
|
||||
```text
|
||||
Mach daraus spec, plan und tasks in einem Rutsch.
|
||||
```
|
||||
|
||||
```text
|
||||
Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren.
|
||||
```
|
||||
|
||||
```text
|
||||
Nimm diesen spec candidate und bereite spec/plan/tasks vor.
|
||||
```
|
||||
|
||||
```text
|
||||
Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell.
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Work strictly repo-based.
|
||||
- Do not implement application code.
|
||||
- Do not modify production code.
|
||||
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
|
||||
- Do not execute implementation commands.
|
||||
- Do not run destructive commands.
|
||||
- Do not expand scope beyond the provided feature idea.
|
||||
- Do not invent architecture that conflicts with repository truth.
|
||||
- Do not create broad platform rewrites when a smaller implementable spec is possible.
|
||||
- Prefer small, reviewable, implementation-ready specs.
|
||||
- Preserve TenantPilot/TenantAtlas terminology.
|
||||
- Follow the repository constitution and existing Spec Kit conventions.
|
||||
- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation.
|
||||
- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates.
|
||||
|
||||
## Required Inputs
|
||||
|
||||
The user should provide at least one of:
|
||||
|
||||
- feature title and short goal
|
||||
- full spec candidate
|
||||
- roadmap item
|
||||
- rough problem statement
|
||||
- UX or architecture improvement idea
|
||||
|
||||
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely.
|
||||
|
||||
## Required Repository Checks
|
||||
|
||||
Before creating or updating Spec Kit artifacts, inspect the relevant repository sources.
|
||||
|
||||
Always check:
|
||||
|
||||
1. `.specify/memory/constitution.md`
|
||||
2. `.specify/templates/`
|
||||
3. `specs/`
|
||||
4. `docs/product/spec-candidates.md`
|
||||
5. relevant roadmap documents under `docs/product/`
|
||||
6. nearby existing specs with related terminology or scope
|
||||
|
||||
Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code.
|
||||
|
||||
## Spec Directory Rules
|
||||
|
||||
Create a new spec directory using the next valid spec number and a kebab-case slug:
|
||||
|
||||
```text
|
||||
specs/<number>-<slug>/
|
||||
```
|
||||
|
||||
The exact number must be derived from the current repository state and existing numbering conventions.
|
||||
|
||||
Create or update only these preparation artifacts inside the selected spec directory:
|
||||
|
||||
```text
|
||||
specs/<number>-<slug>/spec.md
|
||||
specs/<number>-<slug>/plan.md
|
||||
specs/<number>-<slug>/tasks.md
|
||||
```
|
||||
|
||||
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files.
|
||||
|
||||
## `spec.md` Requirements
|
||||
|
||||
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
|
||||
|
||||
Include:
|
||||
|
||||
- Feature title
|
||||
- Problem statement
|
||||
- Business/product value
|
||||
- Primary users/operators
|
||||
- User stories
|
||||
- Functional requirements
|
||||
- Non-functional requirements
|
||||
- UX requirements
|
||||
- RBAC/security requirements
|
||||
- Auditability/observability requirements
|
||||
- Data/truth-source requirements where relevant
|
||||
- Out of scope
|
||||
- Acceptance criteria
|
||||
- Success criteria
|
||||
- Risks
|
||||
- Assumptions
|
||||
- Open questions
|
||||
|
||||
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
|
||||
|
||||
- workspace/tenant isolation
|
||||
- capability-first RBAC
|
||||
- auditability
|
||||
- operation/result truth separation
|
||||
- source-of-truth clarity
|
||||
- calm enterprise operator UX
|
||||
- progressive disclosure where useful
|
||||
- no false positive calmness
|
||||
|
||||
## `plan.md` Requirements
|
||||
|
||||
The plan must be repo-aware and implementation-oriented, but still must not implement.
|
||||
|
||||
Include:
|
||||
|
||||
- Technical approach
|
||||
- Existing repository surfaces likely affected
|
||||
- Domain/model implications
|
||||
- UI/Filament implications
|
||||
- Livewire implications where relevant
|
||||
- OperationRun/monitoring implications where relevant
|
||||
- RBAC/policy implications
|
||||
- Audit/logging/evidence implications where relevant
|
||||
- Data/migration implications where relevant
|
||||
- Test strategy
|
||||
- Rollout considerations
|
||||
- Risk controls
|
||||
- Implementation phases
|
||||
|
||||
The plan should clearly distinguish:
|
||||
|
||||
- execution truth
|
||||
- artifact truth
|
||||
- backup/snapshot truth
|
||||
- recovery/evidence truth
|
||||
- operator next action
|
||||
|
||||
Use those distinctions only where relevant to the feature.
|
||||
|
||||
## `tasks.md` Requirements
|
||||
|
||||
Tasks must be ordered, small, and verifiable.
|
||||
|
||||
Include:
|
||||
|
||||
- checkbox tasks
|
||||
- phase grouping
|
||||
- tests before or alongside implementation tasks where practical
|
||||
- final validation tasks
|
||||
- documentation/update tasks if needed
|
||||
- explicit non-goals where useful
|
||||
|
||||
Avoid vague tasks such as:
|
||||
|
||||
```text
|
||||
Clean up code
|
||||
Refactor UI
|
||||
Improve performance
|
||||
Make it enterprise-ready
|
||||
```
|
||||
|
||||
Prefer concrete tasks such as:
|
||||
|
||||
```text
|
||||
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
|
||||
- [ ] Update <specific Filament page/resource> to display <specific state>.
|
||||
- [ ] Add policy coverage for <specific capability>.
|
||||
```
|
||||
|
||||
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
|
||||
|
||||
## Scope Control
|
||||
|
||||
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
|
||||
|
||||
Examples of follow-up candidates:
|
||||
|
||||
- assigned findings
|
||||
- pending approvals
|
||||
- personal work queue
|
||||
- notification delivery settings
|
||||
- evidence pack export hardening
|
||||
- operation monitoring refinements
|
||||
- autonomous governance decision surfaces
|
||||
|
||||
Do not force all follow-up candidates into the primary spec.
|
||||
|
||||
## Final Response Requirements
|
||||
|
||||
After creating or updating the artifacts, respond with:
|
||||
|
||||
1. Created or updated spec directory
|
||||
2. Files created or updated
|
||||
3. Important repo-based adjustments made
|
||||
4. Assumptions made
|
||||
5. Open questions, if any
|
||||
6. Recommended next manual analysis prompt
|
||||
7. Explicit statement that no implementation was performed
|
||||
|
||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||
|
||||
## Required Next Manual Analysis Prompt
|
||||
|
||||
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
|
||||
|
||||
```markdown
|
||||
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
|
||||
|
||||
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
|
||||
|
||||
Ziel:
|
||||
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
|
||||
|
||||
Wichtig:
|
||||
- Keine Implementierung.
|
||||
- Keine Codeänderungen.
|
||||
- Keine Scope-Erweiterung.
|
||||
- Prüfe nur gegen Repo-Wahrheit.
|
||||
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
|
||||
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
|
||||
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
|
||||
```
|
||||
|
||||
## Example Invocation
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. Inspect constitution, templates, specs, roadmap, and candidate docs.
|
||||
2. Determine the next valid spec number.
|
||||
3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory.
|
||||
4. Keep scope tight.
|
||||
5. Do not implement.
|
||||
6. Return the summary and next manual analysis prompt.
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -51,7 +50,7 @@ public function handle(): int
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::DirectoryGroupsSync->value,
|
||||
type: 'entra_group_sync',
|
||||
identityInputs: [
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => $slotKey,
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -169,12 +168,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => OperationRunType::BackupSchedulePurge->value,
|
||||
'type' => 'backup_schedule_purge',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', implode(':', [
|
||||
(string) $tenant->id,
|
||||
OperationRunType::BackupSchedulePurge->value,
|
||||
'backup_schedule_purge',
|
||||
now()->toISOString(),
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
|
||||
@ -7,9 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
@ -30,7 +28,7 @@ public function handle(
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = OperationRun::query()
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||
->where('type', 'backup_schedule_run')
|
||||
->whereIn('status', ['queued', 'running']);
|
||||
|
||||
if ($olderThanMinutes > 0) {
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
@ -490,7 +489,7 @@ private function compareNowAction(): Action
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value)
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
@ -811,8 +810,8 @@ private function compareAssignedTenants(): void
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
$toast = (int) $result['queuedCount'] > 0
|
||||
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
|
||||
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
|
||||
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||
|
||||
$toast
|
||||
->body($summary.' Open Operations for progress and next steps.')
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -562,6 +561,6 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
||||
|
||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||
{
|
||||
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value);
|
||||
return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,7 @@
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
@ -509,14 +507,12 @@ private function canResumeCapture(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$canonicalType = OperationCatalog::canonicalCode((string) $this->run->type);
|
||||
|
||||
if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
|
||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$tokenKey = $canonicalType === OperationRunType::BaselineCapture->value
|
||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
||||
? 'baseline_capture.resume_token'
|
||||
: 'baseline_compare.resume_token';
|
||||
$token = data_get($context, $tokenKey);
|
||||
|
||||
@ -42,7 +42,6 @@
|
||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||
use App\Support\Onboarding\OnboardingDraftStage;
|
||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -52,9 +51,6 @@
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||
@ -321,7 +317,7 @@ public function content(Schema $schema): Schema
|
||||
Section::make('Tenant')
|
||||
->schema([
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Tenant ID (GUID)')
|
||||
->label('Entra Tenant ID (GUID)')
|
||||
->required()
|
||||
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
||||
->rules(['uuid'])
|
||||
@ -427,8 +423,7 @@ public function content(Schema $schema): Schema
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Target scope ID')
|
||||
->helperText('Provider-owned Microsoft tenant detail for this selected target scope.')
|
||||
->label('Directory (tenant) ID')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
Toggle::make('uses_dedicated_override')
|
||||
@ -466,13 +461,6 @@ public function content(Schema $schema): Schema
|
||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
->maxLength(255)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||
TextInput::make('new_connection.target_scope_id')
|
||||
->label('Target scope ID')
|
||||
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||
->helperText('The provider connection will point to this tenant target scope.'),
|
||||
TextInput::make('new_connection.connection_type')
|
||||
->label('Connection type')
|
||||
->default('Platform connection')
|
||||
@ -669,7 +657,7 @@ public function content(Schema $schema): Schema
|
||||
UnorderedList::make([
|
||||
'Tenant status will be set to Active.',
|
||||
'Backup, inventory, and compliance operations become available.',
|
||||
'The provider connection will be used for provider API calls.',
|
||||
'The provider connection will be used for all Graph API calls.',
|
||||
]),
|
||||
]),
|
||||
Toggle::make('override_blocked')
|
||||
@ -768,7 +756,7 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
||||
|
||||
foreach ($operationTypes as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$normalizedValue = $this->normalizeBootstrapOperationType($value);
|
||||
$normalizedValue = trim($value);
|
||||
|
||||
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
||||
$normalized[] = $normalizedValue;
|
||||
@ -788,7 +776,7 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
||||
default => false,
|
||||
};
|
||||
|
||||
$normalizedKey = $this->normalizeBootstrapOperationType($key);
|
||||
$normalizedKey = trim($key);
|
||||
|
||||
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
||||
$normalized[] = $normalizedKey;
|
||||
@ -798,24 +786,13 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
private function normalizeBootstrapOperationType(string $operationType): string
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
if ($operationType === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return OperationCatalog::canonicalCode($operationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function supportedBootstrapCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
];
|
||||
}
|
||||
@ -1616,7 +1593,6 @@ private function initializeWizardData(): void
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
||||
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id;
|
||||
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
||||
$this->data['name'] ??= (string) $tenant->name;
|
||||
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
||||
@ -1700,56 +1676,14 @@ private function providerConnectionOptions(): array
|
||||
}
|
||||
|
||||
return ProviderConnection::query()
|
||||
->with('tenant')
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('display_name')
|
||||
->get()
|
||||
->mapWithKeys(fn (ProviderConnection $connection): array => [
|
||||
(int) $connection->getKey() => sprintf(
|
||||
'%s — %s',
|
||||
(string) $connection->display_name,
|
||||
$this->providerConnectionTargetScopeSummary($connection),
|
||||
),
|
||||
])
|
||||
->pluck('display_name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string
|
||||
{
|
||||
try {
|
||||
return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Target scope needs review';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array
|
||||
{
|
||||
try {
|
||||
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra);
|
||||
} catch (InvalidArgumentException) {
|
||||
return array_merge([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'target_scope' => [
|
||||
'provider' => (string) $connection->provider,
|
||||
'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
'scope_identifier' => (string) $connection->entra_tenant_id,
|
||||
'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id),
|
||||
'shared_label' => 'Target scope',
|
||||
'shared_help_text' => 'The platform scope this provider connection represents.',
|
||||
],
|
||||
'provider_identity_context' => [],
|
||||
], $extra);
|
||||
}
|
||||
}
|
||||
|
||||
private function verificationStatusLabel(): string
|
||||
{
|
||||
return BadgeCatalog::spec(
|
||||
@ -2665,11 +2599,12 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
workspace: $this->workspace,
|
||||
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
||||
context: [
|
||||
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $tenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||
]),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
status: 'success',
|
||||
@ -2722,22 +2657,6 @@ public function createProviderConnection(array $data): void
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: (string) $tenant->tenant_id,
|
||||
scopeDisplayName: $displayName,
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => (string) $tenant->tenant_id,
|
||||
],
|
||||
);
|
||||
|
||||
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
throw ValidationException::withMessages([
|
||||
'new_connection.target_scope_id' => $targetScope['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($usesDedicatedCredential) {
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||
}
|
||||
@ -2814,11 +2733,14 @@ public function createProviderConnection(array $data): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.created',
|
||||
context: [
|
||||
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'connection_type' => $connection->connection_type->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -2834,12 +2756,15 @@ public function createProviderConnection(array $data): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'from_connection_type' => $previousConnectionType->value,
|
||||
'to_connection_type' => $connection->connection_type->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -3355,7 +3280,7 @@ private function dispatchBootstrapJob(
|
||||
OperationRun $run,
|
||||
): void {
|
||||
match ($operationType) {
|
||||
'inventory.sync' => ProviderInventorySyncJob::dispatch(
|
||||
'inventory_sync' => ProviderInventorySyncJob::dispatch(
|
||||
tenantId: $tenantId,
|
||||
userId: $userId,
|
||||
providerConnectionId: $providerConnectionId,
|
||||
@ -4379,12 +4304,15 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
tenant: $this->managedTenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'from_connection_type' => $existingType->value,
|
||||
'to_connection_type' => $targetType->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: (string) $user->email,
|
||||
@ -4400,12 +4328,15 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
||||
tenant: $this->managedTenant,
|
||||
action: 'provider_connection.updated',
|
||||
context: [
|
||||
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||
'fields' => $changedFields,
|
||||
'connection_type' => $targetType->value,
|
||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: (string) $user->email,
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -458,7 +457,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -529,7 +528,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -756,7 +755,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -853,7 +852,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
|
||||
@ -32,8 +32,6 @@
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
@ -875,7 +873,7 @@ private static function latestBaselineCaptureEnvelope(BaselineProfile $profile):
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCapture->value))
|
||||
->where('type', 'baseline_capture')
|
||||
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||
->where('status', 'completed')
|
||||
->orderByDesc('completed_at')
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
@ -341,8 +340,8 @@ private function compareAssignedTenantsAction(): Action
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
$toast = (int) $result['queuedCount'] > 0
|
||||
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
|
||||
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
|
||||
? OperationUxPresenter::queuedToast('baseline_compare')
|
||||
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
|
||||
|
||||
$toast
|
||||
->body($summary.' Open Operations for progress and next steps.')
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -176,7 +175,7 @@ protected function getHeaderActions(): array
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::InventorySync->value,
|
||||
type: 'inventory_sync',
|
||||
identityInputs: [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
],
|
||||
|
||||
@ -27,7 +27,6 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
@ -231,9 +230,7 @@ public static function table(Table $table): Table
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical(
|
||||
OperationCatalog::canonicalCode($value),
|
||||
));
|
||||
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||
@ -414,7 +411,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCompare->value) {
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
@ -469,7 +466,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
}
|
||||
}
|
||||
|
||||
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCapture->value) {
|
||||
if ((string) $record->type === 'baseline_capture') {
|
||||
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
||||
|
||||
if ($baselineCaptureEvidence !== []) {
|
||||
@ -1449,7 +1446,7 @@ private static function reconciliationPayload(OperationRun $record): array
|
||||
*/
|
||||
private static function inventorySyncCoverageSection(OperationRun $record): ?array
|
||||
{
|
||||
if (OperationCatalog::canonicalCode((string) $record->type) !== OperationRunType::InventorySync->value) {
|
||||
if ((string) $record->type !== 'inventory_sync') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -20,15 +20,12 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -53,7 +50,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use UnitEnum;
|
||||
|
||||
class ProviderConnectionResource extends Resource
|
||||
@ -488,62 +484,6 @@ private static function verificationStatusLabelFromState(mixed $state): string
|
||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
||||
}
|
||||
|
||||
private static function targetScopeHelpText(): string
|
||||
{
|
||||
return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.';
|
||||
}
|
||||
|
||||
private static function targetScopeSummary(?ProviderConnection $record): string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return 'Target scope is set when this connection is saved.';
|
||||
}
|
||||
|
||||
try {
|
||||
return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary();
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Target scope needs review';
|
||||
}
|
||||
}
|
||||
|
||||
private static function providerIdentityContext(?ProviderConnection $record): ?string
|
||||
{
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine();
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array
|
||||
{
|
||||
try {
|
||||
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra);
|
||||
} catch (InvalidArgumentException) {
|
||||
return array_merge([
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => (string) $record->provider,
|
||||
'target_scope' => [
|
||||
'provider' => (string) $record->provider,
|
||||
'scope_kind' => 'tenant',
|
||||
'scope_identifier' => (string) $record->entra_tenant_id,
|
||||
'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id),
|
||||
'shared_label' => 'Target scope',
|
||||
'shared_help_text' => static::targetScopeHelpText(),
|
||||
],
|
||||
'provider_identity_context' => [],
|
||||
], $extra);
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -556,17 +496,11 @@ public static function form(Schema $schema): Schema
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->maxLength(255),
|
||||
TextInput::make('entra_tenant_id')
|
||||
->label('Target scope ID')
|
||||
->label('Entra tenant ID')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText(static::targetScopeHelpText())
|
||||
->validationAttribute('target scope ID')
|
||||
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||
->rules(['uuid']),
|
||||
Placeholder::make('target_scope_display')
|
||||
->label('Target scope')
|
||||
->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||
->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection),
|
||||
Placeholder::make('connection_type_display')
|
||||
->label('Connection type')
|
||||
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
|
||||
@ -629,9 +563,8 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Display name'),
|
||||
Infolists\Components\TextEntry::make('provider')
|
||||
->label('Provider'),
|
||||
Infolists\Components\TextEntry::make('target_scope')
|
||||
->label('Target scope')
|
||||
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||
Infolists\Components\TextEntry::make('entra_tenant_id')
|
||||
->label('Entra tenant ID')
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('connection_type')
|
||||
->label('Connection type')
|
||||
@ -681,11 +614,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||
Infolists\Components\TextEntry::make('provider_identity_context')
|
||||
->label('Provider identity details')
|
||||
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
|
||||
->placeholder('n/a')
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||
->label('Last error reason')
|
||||
->placeholder('n/a'),
|
||||
@ -743,15 +671,9 @@ public static function table(Table $table): Table
|
||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('provider')
|
||||
->label('Provider')
|
||||
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
|
||||
Tables\Columns\TextColumn::make('target_scope')
|
||||
->label('Target scope')
|
||||
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
||||
Tables\Columns\TextColumn::make('connection_type')
|
||||
->label('Connection type')
|
||||
->badge()
|
||||
@ -950,7 +872,7 @@ public static function makeInventorySyncAction(): Actions\Action
|
||||
static::handleProviderOperationAction(
|
||||
record: $record,
|
||||
gate: $gate,
|
||||
operationType: OperationRunType::InventorySync->value,
|
||||
operationType: 'inventory_sync',
|
||||
blockedTitle: 'Inventory sync blocked',
|
||||
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
@ -1027,7 +949,10 @@ public static function makeSetDefaultAction(): Actions\Action
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.default_set',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record),
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1089,12 +1014,15 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'to_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'client_id' => (string) $data['client_id'],
|
||||
'source' => $source,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1233,11 +1161,14 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.connection_type_changed',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $record->getKey(),
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'to_connection_type' => ProviderConnectionType::Platform->value,
|
||||
'source' => $source,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1302,12 +1233,14 @@ public static function makeEnableConnectionAction(): Actions\Action
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.enabled',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'enabled',
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'credentials_present' => $hadCredentials,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -1369,10 +1302,12 @@ public static function makeDisableConnectionAction(): Actions\Action
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.disabled',
|
||||
context: [
|
||||
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'disabled',
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
|
||||
@ -9,12 +9,9 @@
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CreateProviderConnection extends CreateRecord
|
||||
{
|
||||
@ -31,21 +28,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
}
|
||||
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
|
||||
scopeDisplayName: (string) ($data['display_name'] ?? ''),
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
|
||||
],
|
||||
);
|
||||
|
||||
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
throw ValidationException::withMessages([
|
||||
'entra_tenant_id' => $targetScope['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -88,9 +70,11 @@ protected function afterCreate(): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.created',
|
||||
context: [
|
||||
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'connection_type' => $record->connection_type->value,
|
||||
]),
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
|
||||
@ -19,8 +19,6 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
@ -28,7 +26,6 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EditProviderConnection extends EditRecord
|
||||
{
|
||||
@ -80,22 +77,6 @@ protected function mutateFormDataBeforeSave(array $data): array
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
unset($data['is_default']);
|
||||
|
||||
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
|
||||
scopeDisplayName: (string) ($data['display_name'] ?? ''),
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
|
||||
],
|
||||
);
|
||||
|
||||
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
throw ValidationException::withMessages([
|
||||
'entra_tenant_id' => $targetScope['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -138,9 +119,11 @@ protected function afterSave(): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.updated',
|
||||
context: [
|
||||
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||
]),
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'fields' => $changedFields,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
@ -156,7 +139,10 @@ protected function afterSave(): void
|
||||
tenant: $tenant,
|
||||
action: 'provider_connection.default_set',
|
||||
context: [
|
||||
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
|
||||
@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
||||
|
||||
public function getTableEmptyStateHeading(): ?string
|
||||
{
|
||||
return 'No provider connections found';
|
||||
return 'No Microsoft connections found';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateActions(): array
|
||||
|
||||
@ -14,9 +14,7 @@
|
||||
use App\Support\Inventory\InventoryKpiBadges;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@ -58,7 +56,7 @@ protected function getStats(): array
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||
->where('type', 'inventory_sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -38,10 +36,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
'tenant_id' => (int) $schedule->tenant_id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => OperationRunType::BackupScheduleRetention->value,
|
||||
'type' => 'backup_schedule_retention',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':'.OperationRunType::BackupScheduleRetention->value.':'.$schedule->id.':'.Str::uuid()->toString()),
|
||||
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
],
|
||||
@ -90,7 +88,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
/** @var Collection<int, int> $keepBackupSetIds */
|
||||
$keepBackupSetIds = OperationRun::query()
|
||||
->where('tenant_id', (int) $schedule->tenant_id)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||
->where('type', 'backup_schedule_run')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||
->whereNotNull('context->backup_set_id')
|
||||
@ -105,7 +103,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
/** @var Collection<int, int> $allBackupSetIds */
|
||||
$allBackupSetIds = OperationRun::query()
|
||||
->where('tenant_id', (int) $schedule->tenant_id)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
|
||||
->where('type', 'backup_schedule_run')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||
->whereNotNull('context->backup_set_id')
|
||||
|
||||
@ -452,11 +452,6 @@ private function logVerificationResult(
|
||||
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
||||
'credential_source' => $identity->credentialSource,
|
||||
'effective_client_id' => $identity->effectiveClientId,
|
||||
'target_scope' => $identity->targetScope?->toArray(),
|
||||
'provider_identity_context' => array_map(
|
||||
static fn ($detail): array => $detail->toArray(),
|
||||
$identity->contextualIdentityDetails,
|
||||
),
|
||||
'reason_code' => $reasonCode,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'previous_consent_status' => $previousConsentStatus,
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -36,11 +34,11 @@ public function tenant(): BelongsTo
|
||||
public function operationRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
|
||||
->whereIn('type', array_values(array_unique(array_merge(
|
||||
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value),
|
||||
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleRetention->value),
|
||||
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupSchedulePurge->value),
|
||||
))))
|
||||
->whereIn('type', [
|
||||
'backup_schedule_run',
|
||||
'backup_schedule_retention',
|
||||
'backup_schedule_purge',
|
||||
])
|
||||
->where('context->backup_schedule_id', (int) $this->getKey());
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +98,7 @@ public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|i
|
||||
: (int) $profile;
|
||||
|
||||
return $query
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||
->where('type', OperationRunType::BaselineCompare->value)
|
||||
->where('context->baseline_profile_id', $profileId);
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $poli
|
||||
foreach ($policy->coveredTypeNames() as $type) {
|
||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||
$typeQuery
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
|
||||
->where('type', $type)
|
||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||
$stateQuery
|
||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||
@ -152,18 +152,12 @@ public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $po
|
||||
return $query
|
||||
->active()
|
||||
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
||||
$coveredRawTypes = collect($coveredTypes)
|
||||
->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$query->whereNotIn('type', $coveredRawTypes);
|
||||
$query->whereNotIn('type', $coveredTypes);
|
||||
|
||||
foreach ($coveredTypes as $type) {
|
||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||
$typeQuery
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
|
||||
->where('type', $type)
|
||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||
$stateQuery
|
||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||
@ -349,7 +343,7 @@ public static function latestCompletedCoverageBearingInventorySyncForTenant(int
|
||||
|
||||
return static::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
@ -484,11 +478,11 @@ public function baselineGapEnvelope(): array
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
|
||||
return match ($this->canonicalOperationType()) {
|
||||
'baseline.compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||
return match ((string) $this->type) {
|
||||
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||
? data_get($context, 'baseline_compare.evidence_gaps')
|
||||
: [],
|
||||
'baseline.capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||
? data_get($context, 'baseline_capture.gaps')
|
||||
: [],
|
||||
default => [],
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class EntraGroupSyncService
|
||||
@ -33,7 +32,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
|
||||
return $this->providerStarts->start(
|
||||
tenant: $tenant,
|
||||
connection: null,
|
||||
operationType: OperationRunType::DirectoryGroupsSync->value,
|
||||
operationType: 'entra_group_sync',
|
||||
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
|
||||
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||
? (int) $run->context['provider_connection_id']
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class RoleDefinitionsSyncService
|
||||
@ -33,7 +32,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
|
||||
return $this->providerStarts->start(
|
||||
tenant: $tenant,
|
||||
connection: null,
|
||||
operationType: OperationRunType::DirectoryRoleDefinitionsSync->value,
|
||||
operationType: 'directory_role_definitions.sync',
|
||||
dispatcher: function (OperationRun $run) use ($tenant): void {
|
||||
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||
? (int) $run->context['provider_connection_id']
|
||||
|
||||
@ -44,7 +44,6 @@ public function collect(Tenant $tenant): array
|
||||
'entries' => $runs->map(static fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => $run->user?->name,
|
||||
|
||||
@ -6,8 +6,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class InventoryMissingService
|
||||
@ -29,7 +27,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', 'completed')
|
||||
->where('context->selection_hash', $selectionHash)
|
||||
->orderByDesc('completed_at')
|
||||
|
||||
@ -59,7 +59,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':'.OperationRunType::InventorySync->value.':'.$selectionHash.':'.Str::uuid()->toString()),
|
||||
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()),
|
||||
'context' => array_merge($normalizedSelection, [
|
||||
'selection_hash' => $selectionHash,
|
||||
]),
|
||||
@ -698,7 +698,7 @@ private function resolveFoundationPolicyAnchor(
|
||||
|
||||
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||
{
|
||||
return sprintf('%s:tenant:%s:selection:%s', OperationRunType::InventorySync->value, (string) $tenant->getKey(), $selectionHash);
|
||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
||||
}
|
||||
|
||||
private function mapGraphFailureToErrorCode(GraphResponse $response): string
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationReportOverall;
|
||||
@ -683,7 +682,7 @@ private function bootstrapOperationTypes(TenantOnboardingSession $draft): array
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(static fn (mixed $value): string => is_string($value) ? OperationCatalog::canonicalCode($value) : '', $types),
|
||||
array_map(static fn (mixed $value): string => is_string($value) ? trim($value) : '', $types),
|
||||
static fn (string $value): bool => $value !== '',
|
||||
));
|
||||
}
|
||||
@ -710,7 +709,7 @@ private function bootstrapRunMap(TenantOnboardingSession $draft, array $selected
|
||||
continue;
|
||||
}
|
||||
|
||||
$runMap[OperationCatalog::canonicalCode($type)] = $normalizedRunId;
|
||||
$runMap[trim($type)] = $normalizedRunId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1271,7 +1271,7 @@ private function writeTerminalAudit(OperationRun $run): void
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'operation_type' => $run->type,
|
||||
'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []),
|
||||
'failure_summary' => $run->failure_summary,
|
||||
'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null),
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\ExecutionAuthorityMode;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||
@ -28,9 +27,9 @@ class QueuedExecutionLegitimacyGate
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SYSTEM_AUTHORITY_ALLOWLIST = [
|
||||
'backup.schedule.execute',
|
||||
'backup.schedule.retention',
|
||||
'backup.schedule.purge',
|
||||
'backup_schedule_run',
|
||||
'backup_schedule_retention',
|
||||
'backup_schedule_purge',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@ -135,7 +134,6 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
||||
public function buildContext(OperationRun $run): QueuedExecutionContext
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$operationType = OperationCatalog::canonicalCode((string) $run->type);
|
||||
$authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null)
|
||||
?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound);
|
||||
$providerConnectionId = $this->resolveProviderConnectionId($context);
|
||||
@ -143,28 +141,26 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
|
||||
|
||||
return new QueuedExecutionContext(
|
||||
run: $run,
|
||||
operationType: $operationType,
|
||||
operationType: (string) $run->type,
|
||||
workspaceId: $workspaceId,
|
||||
tenant: $run->tenant,
|
||||
initiator: $run->user,
|
||||
authorityMode: $authorityMode,
|
||||
requiredCapability: is_string($context['required_capability'] ?? null)
|
||||
? $context['required_capability']
|
||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType((string) $run->type),
|
||||
providerConnectionId: $providerConnectionId,
|
||||
targetScope: [
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
'provider_connection_id' => $providerConnectionId,
|
||||
],
|
||||
prerequisiteClasses: $this->prerequisiteClassesFor($operationType, $providerConnectionId),
|
||||
prerequisiteClasses: $this->prerequisiteClassesFor((string) $run->type, $providerConnectionId),
|
||||
);
|
||||
}
|
||||
|
||||
public function isSystemAuthorityAllowed(string $operationType): bool
|
||||
{
|
||||
$operationType = OperationCatalog::canonicalCode($operationType);
|
||||
|
||||
return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true);
|
||||
}
|
||||
|
||||
|
||||
@ -4,19 +4,11 @@
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||
|
||||
final class PlatformProviderIdentityResolver
|
||||
{
|
||||
/**
|
||||
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||
*/
|
||||
public function resolve(
|
||||
string $tenantContext,
|
||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||
array $contextualIdentityDetails = [],
|
||||
): ProviderIdentityResolution {
|
||||
public function resolve(string $tenantContext): ProviderIdentityResolution
|
||||
{
|
||||
$targetTenant = trim($tenantContext);
|
||||
$clientId = trim((string) config('graph.client_id'));
|
||||
$clientSecret = trim((string) config('graph.client_secret'));
|
||||
@ -30,8 +22,6 @@ public function resolve(
|
||||
credentialSource: 'platform_config',
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
message: 'Provider connection is missing target tenant scope.',
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@ -42,8 +32,6 @@ public function resolve(
|
||||
credentialSource: 'platform_config',
|
||||
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
|
||||
message: 'Platform app identity is not configured.',
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,8 +42,6 @@ public function resolve(
|
||||
credentialSource: 'platform_config',
|
||||
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||
message: 'Platform app identity is incomplete.',
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,13 +53,6 @@ public function resolve(
|
||||
clientSecret: $clientSecret,
|
||||
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
|
||||
redirectUri: $redirectUri,
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails !== []
|
||||
? array_values(array_merge($contextualIdentityDetails, array_filter([
|
||||
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
|
||||
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
||||
])))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ public function enableDedicatedOverride(
|
||||
$clientSecret = trim($clientSecret);
|
||||
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
throw new InvalidArgumentException('Dedicated app (client) ID and client secret are required.');
|
||||
throw new InvalidArgumentException('Dedicated client_id and client_secret are required.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {
|
||||
|
||||
@ -4,38 +4,25 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||
|
||||
final class ProviderConnectionResolution
|
||||
{
|
||||
/**
|
||||
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly bool $resolved,
|
||||
public readonly ?ProviderConnection $connection,
|
||||
public readonly ?string $reasonCode,
|
||||
public readonly ?string $extensionReasonCode,
|
||||
public readonly ?string $message,
|
||||
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||
public readonly array $contextualIdentityDetails,
|
||||
) {}
|
||||
|
||||
public static function resolved(ProviderConnection $connection): self
|
||||
{
|
||||
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
|
||||
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||
|
||||
return new self(
|
||||
resolved: true,
|
||||
connection: $connection,
|
||||
reasonCode: null,
|
||||
extensionReasonCode: null,
|
||||
message: null,
|
||||
targetScope: $normalizer->descriptorForConnection($connection),
|
||||
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,29 +32,12 @@ public static function blocked(
|
||||
?string $extensionReasonCode = null,
|
||||
?ProviderConnection $connection = null,
|
||||
): self {
|
||||
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
|
||||
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||
$targetScope = null;
|
||||
$contextualIdentityDetails = [];
|
||||
|
||||
if ($connection instanceof ProviderConnection) {
|
||||
$normalization = $normalizer->normalizeConnection($connection);
|
||||
$descriptor = $normalization['target_scope'] ?? null;
|
||||
|
||||
if ($descriptor instanceof ProviderConnectionTargetScopeDescriptor) {
|
||||
$targetScope = $descriptor;
|
||||
$contextualIdentityDetails = $normalizer->contextualIdentityDetailsForConnection($connection);
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
resolved: false,
|
||||
connection: $connection,
|
||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||
extensionReasonCode: $extensionReasonCode,
|
||||
message: $message,
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,11 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
|
||||
final class ProviderConnectionResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderIdentityResolver $identityResolver,
|
||||
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
||||
) {}
|
||||
|
||||
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution
|
||||
@ -65,19 +63,11 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
|
||||
);
|
||||
}
|
||||
|
||||
$targetScope = $this->targetScopeNormalizer->normalizeConnection($connection);
|
||||
|
||||
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
$failureCode = $targetScope['failure_code'] ?? ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT;
|
||||
|
||||
if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
|
||||
? ProviderReasonCodes::ProviderBindingUnsupported
|
||||
: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
$targetScope['message'] ?? 'Provider connection target scope is invalid.',
|
||||
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
|
||||
? 'ext.connection_scope_unsupported'
|
||||
: 'ext.connection_scope_missing',
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
'Provider connection is missing target tenant scope.',
|
||||
'ext.connection_tenant_missing',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,16 +6,10 @@
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
|
||||
final class ProviderConnectionStateProjector
|
||||
{
|
||||
public function surfaceSummary(ProviderConnection $connection): ProviderConnectionSurfaceSummary
|
||||
{
|
||||
return ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* consent_status: ProviderConsentStatus,
|
||||
|
||||
@ -4,14 +4,9 @@
|
||||
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||
|
||||
final class ProviderIdentityResolution
|
||||
{
|
||||
/**
|
||||
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly bool $resolved,
|
||||
public readonly ProviderConnectionType $connectionType,
|
||||
@ -23,8 +18,6 @@ private function __construct(
|
||||
public readonly ?string $redirectUri,
|
||||
public readonly ?string $reasonCode,
|
||||
public readonly ?string $message,
|
||||
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||
public readonly array $contextualIdentityDetails,
|
||||
) {}
|
||||
|
||||
public static function resolved(
|
||||
@ -35,8 +28,6 @@ public static function resolved(
|
||||
?string $clientSecret,
|
||||
?string $authorityTenant,
|
||||
?string $redirectUri,
|
||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||
array $contextualIdentityDetails = [],
|
||||
): self {
|
||||
return new self(
|
||||
resolved: true,
|
||||
@ -49,10 +40,6 @@ public static function resolved(
|
||||
redirectUri: $redirectUri,
|
||||
reasonCode: null,
|
||||
message: null,
|
||||
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
|
||||
contextualIdentityDetails: $contextualIdentityDetails !== []
|
||||
? $contextualIdentityDetails
|
||||
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,8 +49,6 @@ public static function blocked(
|
||||
string $credentialSource,
|
||||
string $reasonCode,
|
||||
?string $message = null,
|
||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||
array $contextualIdentityDetails = [],
|
||||
): self {
|
||||
return new self(
|
||||
resolved: false,
|
||||
@ -76,10 +61,6 @@ public static function blocked(
|
||||
redirectUri: null,
|
||||
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
|
||||
message: $message,
|
||||
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
|
||||
contextualIdentityDetails: $contextualIdentityDetails !== []
|
||||
? $contextualIdentityDetails
|
||||
: self::contextualIdentityDetails($tenantContext),
|
||||
);
|
||||
}
|
||||
|
||||
@ -87,36 +68,4 @@ public function effectiveReasonCode(): string
|
||||
{
|
||||
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
||||
}
|
||||
|
||||
private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor
|
||||
{
|
||||
$identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations';
|
||||
|
||||
return ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: $identifier,
|
||||
scopeDisplayName: $identifier,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ProviderIdentityContextMetadata>
|
||||
*/
|
||||
private static function contextualIdentityDetails(
|
||||
string $tenantContext,
|
||||
?string $authorityTenant = null,
|
||||
?string $redirectUri = null,
|
||||
): array {
|
||||
$details = [
|
||||
ProviderIdentityContextMetadata::microsoftTenantId($tenantContext),
|
||||
ProviderIdentityContextMetadata::authorityTenant($authorityTenant),
|
||||
ProviderIdentityContextMetadata::redirectUri($redirectUri),
|
||||
];
|
||||
|
||||
return array_values(array_filter(
|
||||
$details,
|
||||
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,6 @@
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
@ -17,16 +15,12 @@ final class ProviderIdentityResolver
|
||||
public function __construct(
|
||||
private readonly PlatformProviderIdentityResolver $platformResolver,
|
||||
private readonly CredentialManager $credentials,
|
||||
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
|
||||
) {}
|
||||
|
||||
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
|
||||
{
|
||||
$tenantContext = trim((string) $connection->entra_tenant_id);
|
||||
$connectionType = $this->resolveConnectionType($connection);
|
||||
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
|
||||
$targetScope = $targetScopeResult['target_scope'] ?? null;
|
||||
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
|
||||
|
||||
if ($connectionType === null) {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
@ -35,20 +29,16 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
|
||||
credentialSource: 'unknown',
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
|
||||
message: 'Provider connection type is invalid.',
|
||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||
if ($tenantContext === '') {
|
||||
return ProviderIdentityResolution::blocked(
|
||||
connectionType: $connectionType,
|
||||
tenantContext: 'organizations',
|
||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
|
||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
message: 'Provider connection is missing target tenant scope.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -59,25 +49,14 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
|
||||
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
|
||||
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||
message: 'Provider connection requires migration review before use.',
|
||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
if ($connectionType === ProviderConnectionType::Platform) {
|
||||
return $this->platformResolver->resolve(
|
||||
tenantContext: $tenantContext,
|
||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
return $this->platformResolver->resolve($tenantContext);
|
||||
}
|
||||
|
||||
return $this->resolveDedicatedIdentity(
|
||||
connection: $connection,
|
||||
tenantContext: $tenantContext,
|
||||
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
return $this->resolveDedicatedIdentity($connection, $tenantContext);
|
||||
}
|
||||
|
||||
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
|
||||
@ -98,8 +77,6 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
|
||||
private function resolveDedicatedIdentity(
|
||||
ProviderConnection $connection,
|
||||
string $tenantContext,
|
||||
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
|
||||
array $contextualIdentityDetails = [],
|
||||
): ProviderIdentityResolution {
|
||||
try {
|
||||
$credentials = $this->credentials->getClientCredentials($connection);
|
||||
@ -112,8 +89,6 @@ private function resolveDedicatedIdentity(
|
||||
? ProviderReasonCodes::DedicatedCredentialInvalid
|
||||
: ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
message: $exception->getMessage(),
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@ -125,8 +100,6 @@ private function resolveDedicatedIdentity(
|
||||
clientSecret: $credentials['client_secret'],
|
||||
authorityTenant: $tenantContext,
|
||||
redirectUri: trim((string) route('admin.consent.callback')),
|
||||
targetScope: $targetScope,
|
||||
contextualIdentityDetails: $contextualIdentityDetails,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -23,8 +23,8 @@ public function definitions(): array
|
||||
'label' => 'Provider connection check',
|
||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||
],
|
||||
'inventory.sync' => [
|
||||
'operation_type' => 'inventory.sync',
|
||||
'inventory_sync' => [
|
||||
'operation_type' => 'inventory_sync',
|
||||
'module' => 'inventory',
|
||||
'label' => 'Inventory sync',
|
||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||
@ -41,14 +41,14 @@ public function definitions(): array
|
||||
'label' => 'Restore execution',
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
],
|
||||
'directory.groups.sync' => [
|
||||
'operation_type' => 'directory.groups.sync',
|
||||
'entra_group_sync' => [
|
||||
'operation_type' => 'entra_group_sync',
|
||||
'module' => 'directory_groups',
|
||||
'label' => 'Directory groups sync',
|
||||
'required_capability' => Capabilities::TENANT_SYNC,
|
||||
],
|
||||
'directory.role_definitions.sync' => [
|
||||
'operation_type' => 'directory.role_definitions.sync',
|
||||
'directory_role_definitions.sync' => [
|
||||
'operation_type' => 'directory_role_definitions.sync',
|
||||
'module' => 'directory_role_definitions',
|
||||
'label' => 'Role definitions sync',
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
@ -77,9 +77,9 @@ public function providerBindings(): array
|
||||
exceptionNotes: 'Current-release provider binding remains Microsoft-only until a real second provider case exists.',
|
||||
),
|
||||
],
|
||||
'inventory.sync' => [
|
||||
'inventory_sync' => [
|
||||
'microsoft' => $this->activeMicrosoftBinding(
|
||||
operationType: 'inventory.sync',
|
||||
operationType: 'inventory_sync',
|
||||
handlerNotes: 'Uses the current Microsoft Intune inventory sync workflow.',
|
||||
exceptionNotes: 'Inventory collection is currently Microsoft Intune-specific provider behavior.',
|
||||
),
|
||||
@ -98,16 +98,16 @@ public function providerBindings(): array
|
||||
exceptionNotes: 'Restore execution remains Microsoft-only and must preserve dry-run and audit safeguards.',
|
||||
),
|
||||
],
|
||||
'directory.groups.sync' => [
|
||||
'entra_group_sync' => [
|
||||
'microsoft' => $this->activeMicrosoftBinding(
|
||||
operationType: 'directory.groups.sync',
|
||||
operationType: 'entra_group_sync',
|
||||
handlerNotes: 'Uses the current Microsoft Entra group synchronization workflow.',
|
||||
exceptionNotes: 'The operation type keeps current Entra vocabulary until the identity-neutrality follow-up.',
|
||||
),
|
||||
],
|
||||
'directory.role_definitions.sync' => [
|
||||
'directory_role_definitions.sync' => [
|
||||
'microsoft' => $this->activeMicrosoftBinding(
|
||||
operationType: 'directory.role_definitions.sync',
|
||||
operationType: 'directory_role_definitions.sync',
|
||||
handlerNotes: 'Uses the current Microsoft directory role definition synchronization workflow.',
|
||||
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
|
||||
),
|
||||
|
||||
@ -597,7 +597,7 @@ private function dispatchFailureAlertSafely(OperationRun $run): void
|
||||
'body' => 'A findings lifecycle backfill run failed.',
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'operation_type' => (string) $run->type,
|
||||
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
|
||||
'view_run_url' => SystemOperationRunLinks::view($run),
|
||||
],
|
||||
|
||||
@ -14,9 +14,10 @@
|
||||
final class OperationRunTriageService
|
||||
{
|
||||
private const RETRYABLE_TYPES = [
|
||||
'inventory.sync',
|
||||
'inventory_sync',
|
||||
'policy.sync',
|
||||
'directory.groups.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
@ -25,9 +26,10 @@ final class OperationRunTriageService
|
||||
];
|
||||
|
||||
private const CANCELABLE_TYPES = [
|
||||
'inventory.sync',
|
||||
'inventory_sync',
|
||||
'policy.sync',
|
||||
'directory.groups.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
@ -44,7 +46,7 @@ public function canRetry(OperationRun $run): bool
|
||||
{
|
||||
return (string) $run->status === OperationRunStatus::Completed->value
|
||||
&& (string) $run->outcome === OperationRunOutcome::Failed->value
|
||||
&& in_array($run->canonicalOperationType(), self::RETRYABLE_TYPES, true);
|
||||
&& in_array((string) $run->type, self::RETRYABLE_TYPES, true);
|
||||
}
|
||||
|
||||
public function canCancel(OperationRun $run): bool
|
||||
@ -53,7 +55,7 @@ public function canCancel(OperationRun $run): bool
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
], true)
|
||||
&& in_array($run->canonicalOperationType(), self::CANCELABLE_TYPES, true);
|
||||
&& in_array((string) $run->type, self::CANCELABLE_TYPES, true);
|
||||
}
|
||||
|
||||
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
@ -81,7 +83,7 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
'user_id' => null,
|
||||
'initiator_name' => $actor->name ?? 'Platform operator',
|
||||
'type' => $run->canonicalOperationType(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
|
||||
@ -98,7 +100,7 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
metadata: [
|
||||
'source_run_id' => (int) $run->getKey(),
|
||||
'new_run_id' => (int) $retryRun->getKey(),
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $retryRun,
|
||||
);
|
||||
@ -150,7 +152,7 @@ public function cancel(OperationRun $run, PlatformUser $actor, string $reason):
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.cancel',
|
||||
metadata: [
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'operation_type' => (string) $run->type,
|
||||
'reason' => $reason,
|
||||
],
|
||||
run: $cancelledRun,
|
||||
@ -190,7 +192,7 @@ public function markInvestigated(OperationRun $run, PlatformUser $actor, string
|
||||
action: 'platform.system_console.mark_investigated',
|
||||
metadata: [
|
||||
'reason' => $reason,
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $run,
|
||||
);
|
||||
|
||||
@ -119,11 +119,6 @@ public function providerConnectionCheckUsingConnection(
|
||||
'connection_type' => $identity->connectionType->value,
|
||||
'credential_source' => $identity->credentialSource,
|
||||
'effective_client_id' => $identity->effectiveClientId,
|
||||
'target_scope' => $identity->targetScope?->toArray(),
|
||||
'provider_identity_context' => array_map(
|
||||
static fn ($detail): array => $detail->toArray(),
|
||||
$identity->contextualIdentityDetails,
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
@ -167,7 +166,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||
->where('type', 'baseline_compare')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -458,7 +457,7 @@ public static function forWidget(?Tenant $tenant): self
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
|
||||
->where('type', 'baseline_compare')
|
||||
->where('context->baseline_profile_id', (string) $profile->getKey())
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
|
||||
@ -121,29 +121,17 @@ public static function boundaryClassification(?PlatformVocabularyGlossary $gloss
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-side compatibility helper for historical rows only.
|
||||
*
|
||||
* Writers must emit the canonical code directly instead of translating a
|
||||
* legacy alias through this method.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function rawValuesForCanonical(string $canonicalCode): array
|
||||
{
|
||||
$canonicalCode = trim($canonicalCode);
|
||||
$values = array_values(array_map(
|
||||
return array_values(array_map(
|
||||
static fn (OperationTypeAlias $alias): string => $alias->rawValue,
|
||||
array_filter(
|
||||
self::operationAliases(),
|
||||
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $canonicalCode,
|
||||
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === trim($canonicalCode),
|
||||
),
|
||||
));
|
||||
|
||||
if (array_key_exists($canonicalCode, self::canonicalDefinitions()) && ! in_array($canonicalCode, $values, true)) {
|
||||
$values[] = $canonicalCode;
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -203,21 +191,6 @@ public static function resolve(string $operationType): OperationTypeResolution
|
||||
);
|
||||
}
|
||||
|
||||
$definition = self::canonicalDefinitions()[$operationType] ?? null;
|
||||
|
||||
if ($definition instanceof CanonicalOperationType) {
|
||||
return new OperationTypeResolution(
|
||||
rawValue: $operationType,
|
||||
canonical: $definition,
|
||||
aliasesConsidered: array_values(array_filter(
|
||||
$aliases,
|
||||
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $operationType,
|
||||
)),
|
||||
aliasStatus: 'canonical',
|
||||
wasLegacyAlias: false,
|
||||
);
|
||||
}
|
||||
|
||||
return new OperationTypeResolution(
|
||||
rawValue: $operationType,
|
||||
canonical: new CanonicalOperationType(
|
||||
@ -289,29 +262,29 @@ private static function operationAliases(): array
|
||||
{
|
||||
return [
|
||||
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
|
||||
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
|
||||
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', true, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
|
||||
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
|
||||
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
|
||||
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
|
||||
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', true, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
|
||||
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
|
||||
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
|
||||
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
|
||||
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', true, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
|
||||
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
|
||||
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
|
||||
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
|
||||
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
||||
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
||||
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
||||
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
|
||||
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
||||
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', true, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
|
||||
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', true, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', true, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
|
||||
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', true, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
|
||||
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
|
||||
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
|
||||
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true),
|
||||
@ -323,9 +296,9 @@ private static function operationAliases(): array
|
||||
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
||||
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
|
||||
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
||||
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
||||
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
||||
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
||||
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
||||
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
||||
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
||||
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
|
||||
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
|
||||
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
|
||||
|
||||
@ -108,7 +108,7 @@ public static function index(
|
||||
}
|
||||
|
||||
if (is_string($operationType) && $operationType !== '') {
|
||||
$parameters['tableFilters']['type']['value'] = OperationCatalog::canonicalCode($operationType);
|
||||
$parameters['tableFilters']['type']['value'] = $operationType;
|
||||
}
|
||||
|
||||
return route('admin.operations.index', $parameters);
|
||||
@ -145,18 +145,17 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
|
||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||
$canonicalType = $run->canonicalOperationType();
|
||||
|
||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
||||
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
||||
}
|
||||
|
||||
if ($canonicalType === 'inventory.sync') {
|
||||
if ($run->type === 'inventory_sync') {
|
||||
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($canonicalType === 'policy.sync') {
|
||||
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
||||
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
|
||||
$policyId = $context['policy_id'] ?? null;
|
||||
@ -165,15 +164,15 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($canonicalType === 'directory.groups.sync') {
|
||||
if ($run->type === 'entra_group_sync') {
|
||||
$links['Directory Groups'] = EntraGroupResource::scopedUrl('index', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($canonicalType === 'baseline.compare') {
|
||||
if ($run->type === 'baseline_compare') {
|
||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($canonicalType === 'baseline.capture') {
|
||||
if ($run->type === 'baseline_capture') {
|
||||
$snapshotId = data_get($context, 'result.snapshot_id');
|
||||
|
||||
if (is_numeric($snapshotId)) {
|
||||
@ -181,7 +180,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($canonicalType === 'backup_set.update') {
|
||||
if ($run->type === 'backup_set.update') {
|
||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
|
||||
$backupSetId = $context['backup_set_id'] ?? null;
|
||||
@ -190,11 +189,11 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) {
|
||||
if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) {
|
||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($canonicalType === 'restore.execute') {
|
||||
if ($run->type === 'restore.execute') {
|
||||
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
|
||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
||||
@ -203,7 +202,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($canonicalType === 'tenant.evidence.snapshot.generate') {
|
||||
if ($run->type === 'tenant.evidence.snapshot.generate') {
|
||||
$snapshot = EvidenceSnapshot::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
@ -214,7 +213,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($canonicalType === 'tenant.review.compose') {
|
||||
if ($run->type === 'tenant.review.compose') {
|
||||
$review = TenantReview::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
@ -225,7 +224,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($canonicalType === 'tenant.review_pack.generate') {
|
||||
if ($run->type === 'tenant.review_pack.generate') {
|
||||
$pack = ReviewPack::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
|
||||
@ -4,16 +4,17 @@
|
||||
|
||||
enum OperationRunType: string
|
||||
{
|
||||
case BaselineCapture = 'baseline.capture';
|
||||
case BaselineCompare = 'baseline.compare';
|
||||
case InventorySync = 'inventory.sync';
|
||||
case BaselineCapture = 'baseline_capture';
|
||||
case BaselineCompare = 'baseline_compare';
|
||||
case InventorySync = 'inventory_sync';
|
||||
case PolicySync = 'policy.sync';
|
||||
case DirectoryGroupsSync = 'directory.groups.sync';
|
||||
case PolicySyncOne = 'policy.sync_one';
|
||||
case DirectoryGroupsSync = 'entra_group_sync';
|
||||
case BackupSetUpdate = 'backup_set.update';
|
||||
case BackupScheduleExecute = 'backup.schedule.execute';
|
||||
case BackupScheduleRetention = 'backup.schedule.retention';
|
||||
case BackupSchedulePurge = 'backup.schedule.purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
||||
case BackupScheduleExecute = 'backup_schedule_run';
|
||||
case BackupScheduleRetention = 'backup_schedule_retention';
|
||||
case BackupSchedulePurge = 'backup_schedule_purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
@ -28,6 +29,24 @@ public static function values(): array
|
||||
|
||||
public function canonicalCode(): string
|
||||
{
|
||||
return $this->value;
|
||||
return match ($this) {
|
||||
self::BaselineCapture => 'baseline.capture',
|
||||
self::BaselineCompare => 'baseline.compare',
|
||||
self::InventorySync => 'inventory.sync',
|
||||
self::PolicySync, self::PolicySyncOne => 'policy.sync',
|
||||
self::DirectoryGroupsSync => 'directory.groups.sync',
|
||||
self::BackupSetUpdate => 'backup_set.update',
|
||||
self::BackupScheduleExecute => 'backup.schedule.execute',
|
||||
self::BackupScheduleRetention => 'backup.schedule.retention',
|
||||
self::BackupSchedulePurge => 'backup.schedule.purge',
|
||||
self::DirectoryRoleDefinitionsSync => 'directory.role_definitions.sync',
|
||||
self::RestoreExecute => 'restore.execute',
|
||||
self::EntraAdminRolesScan => 'entra.admin_roles.scan',
|
||||
self::ReviewPackGenerate => 'tenant.review_pack.generate',
|
||||
self::TenantReviewCompose => 'tenant.review.compose',
|
||||
self::EvidenceSnapshotGenerate => 'tenant.evidence.snapshot.generate',
|
||||
self::RbacHealthCheck => 'rbac.health_check',
|
||||
default => $this->value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class OperationLifecyclePolicy
|
||||
@ -44,8 +43,7 @@ public function definition(string $operationType): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$canonicalType = OperationCatalog::canonicalCode($operationType);
|
||||
$definition = $this->coveredTypes()[$canonicalType] ?? null;
|
||||
$definition = $this->coveredTypes()[$operationType] ?? null;
|
||||
|
||||
if (! is_array($definition)) {
|
||||
return null;
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
|
||||
final class OperationRunCapabilityResolver
|
||||
{
|
||||
@ -15,18 +14,18 @@ public function requiredCapabilityForRun(OperationRun $run): ?string
|
||||
|
||||
public function requiredCapabilityForType(string $operationType): ?string
|
||||
{
|
||||
$operationType = OperationCatalog::canonicalCode($operationType);
|
||||
$operationType = trim($operationType);
|
||||
|
||||
if ($operationType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($operationType) {
|
||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
||||
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'entra_group_sync' => Capabilities::TENANT_SYNC,
|
||||
'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||
|
||||
@ -41,15 +40,15 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
|
||||
public function requiredExecutionCapabilityForType(string $operationType): ?string
|
||||
{
|
||||
$operationType = OperationCatalog::canonicalCode($operationType);
|
||||
$operationType = trim($operationType);
|
||||
|
||||
if ($operationType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($operationType) {
|
||||
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||
'provider.connection.check', 'provider.inventory.sync', 'provider.compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||
'policy.sync', 'policy.sync_one', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||
|
||||
@ -567,7 +567,7 @@ private static function terminalSupportingLines(OperationRun $run): array
|
||||
|
||||
private static function baselineTruthChangeLine(OperationRun $run): ?string
|
||||
{
|
||||
if ($run->canonicalOperationType() !== 'baseline.capture') {
|
||||
if ((string) $run->type !== 'baseline_capture') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers\TargetScope;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
|
||||
final class ProviderConnectionSurfaceSummary
|
||||
{
|
||||
/**
|
||||
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $provider,
|
||||
public readonly ProviderConnectionTargetScopeDescriptor $targetScope,
|
||||
public readonly string $consentState,
|
||||
public readonly string $verificationState,
|
||||
public readonly string $readinessSummary,
|
||||
public readonly array $contextualIdentityDetails = [],
|
||||
) {}
|
||||
|
||||
public static function forConnection(ProviderConnection $connection): self
|
||||
{
|
||||
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
|
||||
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
|
||||
$targetScope = $normalizer->descriptorForConnection($connection);
|
||||
$consentState = self::stateValue($connection->consent_status);
|
||||
$verificationState = self::stateValue($connection->verification_status);
|
||||
|
||||
return new self(
|
||||
provider: trim((string) $connection->provider),
|
||||
targetScope: $targetScope,
|
||||
consentState: $consentState,
|
||||
verificationState: $verificationState,
|
||||
readinessSummary: self::readinessSummary(
|
||||
isEnabled: (bool) $connection->is_enabled,
|
||||
consentState: $consentState,
|
||||
verificationState: $verificationState,
|
||||
),
|
||||
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
|
||||
);
|
||||
}
|
||||
|
||||
public function targetScopeSummary(): string
|
||||
{
|
||||
return $this->targetScope->summary();
|
||||
}
|
||||
|
||||
public function contextualIdentityLine(): ?string
|
||||
{
|
||||
if ($this->contextualIdentityDetails === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($this->contextualIdentityDetails)
|
||||
->map(fn (ProviderIdentityContextMetadata $detail): string => sprintf('%s: %s', $detail->detailLabel, $detail->detailValue))
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* provider: string,
|
||||
* target_scope: array<string, string>,
|
||||
* consent_state: string,
|
||||
* verification_state: string,
|
||||
* readiness_summary: string,
|
||||
* contextual_identity_details: list<array<string, string>>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'provider' => $this->provider,
|
||||
'target_scope' => $this->targetScope->toArray(),
|
||||
'consent_state' => $this->consentState,
|
||||
'verification_state' => $this->verificationState,
|
||||
'readiness_summary' => $this->readinessSummary,
|
||||
'contextual_identity_details' => array_map(
|
||||
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||
$this->contextualIdentityDetails,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private static function stateValue(mixed $state): string
|
||||
{
|
||||
if ($state instanceof ProviderConsentStatus || $state instanceof ProviderVerificationStatus) {
|
||||
return $state->value;
|
||||
}
|
||||
|
||||
return trim((string) $state);
|
||||
}
|
||||
|
||||
private static function readinessSummary(bool $isEnabled, string $consentState, string $verificationState): string
|
||||
{
|
||||
if (! $isEnabled) {
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||
return sprintf(
|
||||
'Consent %s',
|
||||
strtolower(BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $consentState)->label),
|
||||
);
|
||||
}
|
||||
|
||||
return match ($verificationState) {
|
||||
ProviderVerificationStatus::Healthy->value => 'Ready',
|
||||
ProviderVerificationStatus::Degraded->value => 'Ready with warnings',
|
||||
ProviderVerificationStatus::Blocked->value => 'Verification blocked',
|
||||
ProviderVerificationStatus::Error->value => 'Verification failed',
|
||||
ProviderVerificationStatus::Pending->value => 'Verification pending',
|
||||
default => 'Verification not run',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers\TargetScope;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ProviderConnectionTargetScopeDescriptor
|
||||
{
|
||||
public const string SCOPE_KIND_TENANT = 'tenant';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $provider,
|
||||
public readonly string $scopeKind,
|
||||
public readonly string $scopeIdentifier,
|
||||
public readonly string $scopeDisplayName,
|
||||
public readonly string $sharedLabel = 'Target scope',
|
||||
public readonly string $sharedHelpText = 'The platform scope this provider connection represents.',
|
||||
) {
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
public static function fromConnection(ProviderConnection $connection): self
|
||||
{
|
||||
$tenantName = is_string($connection->tenant?->name) && trim($connection->tenant->name) !== ''
|
||||
? trim($connection->tenant->name)
|
||||
: trim((string) $connection->display_name);
|
||||
|
||||
return new self(
|
||||
provider: trim((string) $connection->provider),
|
||||
scopeKind: self::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: trim((string) $connection->entra_tenant_id),
|
||||
scopeDisplayName: $tenantName !== '' ? $tenantName : trim((string) $connection->entra_tenant_id),
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromInput(
|
||||
string $provider,
|
||||
string $scopeKind,
|
||||
string $scopeIdentifier,
|
||||
?string $scopeDisplayName = null,
|
||||
): self {
|
||||
$scopeIdentifier = trim($scopeIdentifier);
|
||||
$displayName = trim((string) $scopeDisplayName);
|
||||
|
||||
return new self(
|
||||
provider: trim($provider),
|
||||
scopeKind: trim($scopeKind),
|
||||
scopeIdentifier: $scopeIdentifier,
|
||||
scopeDisplayName: $displayName !== '' ? $displayName : $scopeIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
public function summary(): string
|
||||
{
|
||||
if ($this->scopeDisplayName !== '' && $this->scopeDisplayName !== $this->scopeIdentifier) {
|
||||
return sprintf('%s (%s)', $this->scopeDisplayName, $this->scopeIdentifier);
|
||||
}
|
||||
|
||||
return $this->scopeIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* provider: string,
|
||||
* scope_kind: string,
|
||||
* scope_identifier: string,
|
||||
* scope_display_name: string,
|
||||
* shared_label: string,
|
||||
* shared_help_text: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'provider' => $this->provider,
|
||||
'scope_kind' => $this->scopeKind,
|
||||
'scope_identifier' => $this->scopeIdentifier,
|
||||
'scope_display_name' => $this->scopeDisplayName,
|
||||
'shared_label' => $this->sharedLabel,
|
||||
'shared_help_text' => $this->sharedHelpText,
|
||||
];
|
||||
}
|
||||
|
||||
private function validate(): void
|
||||
{
|
||||
if ($this->provider === '') {
|
||||
throw new InvalidArgumentException('Provider is required for target-scope descriptors.');
|
||||
}
|
||||
|
||||
if ($this->scopeKind !== self::SCOPE_KIND_TENANT) {
|
||||
throw new InvalidArgumentException('Unsupported provider connection target-scope kind.');
|
||||
}
|
||||
|
||||
if ($this->scopeIdentifier === '') {
|
||||
throw new InvalidArgumentException('Target scope identifier is required.');
|
||||
}
|
||||
|
||||
if ($this->scopeDisplayName === '') {
|
||||
throw new InvalidArgumentException('Target scope display name is required.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers\TargetScope;
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ProviderConnectionTargetScopeNormalizer
|
||||
{
|
||||
public const string STATUS_NORMALIZED = 'normalized';
|
||||
|
||||
public const string STATUS_BLOCKED = 'blocked';
|
||||
|
||||
public const string FAILURE_NONE = 'none';
|
||||
|
||||
public const string FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION = 'unsupported_provider_scope_combination';
|
||||
|
||||
public const string FAILURE_MISSING_PROVIDER_CONTEXT = 'missing_provider_context';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $providerSpecificIdentity
|
||||
* @return array{
|
||||
* status: string,
|
||||
* provider: string,
|
||||
* scope_kind: string,
|
||||
* target_scope?: ProviderConnectionTargetScopeDescriptor,
|
||||
* contextual_identity_details?: list<ProviderIdentityContextMetadata>,
|
||||
* preview_summary?: ?ProviderConnectionSurfaceSummary,
|
||||
* failure_code: string,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function normalizeInput(
|
||||
string $provider,
|
||||
string $scopeKind,
|
||||
string $scopeIdentifier,
|
||||
?string $scopeDisplayName = null,
|
||||
array $providerSpecificIdentity = [],
|
||||
): array {
|
||||
$provider = trim($provider);
|
||||
$scopeKind = trim($scopeKind);
|
||||
$scopeIdentifier = trim($scopeIdentifier);
|
||||
|
||||
if ($provider !== 'microsoft' || $scopeKind !== ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) {
|
||||
return $this->blocked(
|
||||
provider: $provider,
|
||||
scopeKind: $scopeKind,
|
||||
failureCode: self::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION,
|
||||
message: 'This provider and target-scope combination is not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($scopeIdentifier === '') {
|
||||
return $this->blocked(
|
||||
provider: $provider,
|
||||
scopeKind: $scopeKind,
|
||||
failureCode: self::FAILURE_MISSING_PROVIDER_CONTEXT,
|
||||
message: 'A target scope identifier is required for this provider connection.',
|
||||
);
|
||||
}
|
||||
|
||||
$descriptor = ProviderConnectionTargetScopeDescriptor::fromInput(
|
||||
provider: $provider,
|
||||
scopeKind: $scopeKind,
|
||||
scopeIdentifier: $scopeIdentifier,
|
||||
scopeDisplayName: $scopeDisplayName,
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => self::STATUS_NORMALIZED,
|
||||
'provider' => $provider,
|
||||
'scope_kind' => $scopeKind,
|
||||
'target_scope' => $descriptor,
|
||||
'contextual_identity_details' => $this->contextualIdentityDetails(
|
||||
provider: $provider,
|
||||
scopeIdentifier: $scopeIdentifier,
|
||||
providerSpecificIdentity: $providerSpecificIdentity,
|
||||
),
|
||||
'preview_summary' => null,
|
||||
'failure_code' => self::FAILURE_NONE,
|
||||
'message' => 'Target scope normalized.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status: string,
|
||||
* provider: string,
|
||||
* scope_kind: string,
|
||||
* target_scope?: ProviderConnectionTargetScopeDescriptor,
|
||||
* contextual_identity_details?: list<ProviderIdentityContextMetadata>,
|
||||
* preview_summary?: ?ProviderConnectionSurfaceSummary,
|
||||
* failure_code: string,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function normalizeConnection(ProviderConnection $connection): array
|
||||
{
|
||||
return $this->normalizeInput(
|
||||
provider: trim((string) $connection->provider),
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: trim((string) $connection->entra_tenant_id),
|
||||
scopeDisplayName: $connection->tenant?->name ?? $connection->display_name,
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function descriptorForConnection(ProviderConnection $connection): ProviderConnectionTargetScopeDescriptor
|
||||
{
|
||||
$result = $this->normalizeConnection($connection);
|
||||
$descriptor = $result['target_scope'] ?? null;
|
||||
|
||||
if (! $descriptor instanceof ProviderConnectionTargetScopeDescriptor) {
|
||||
throw new InvalidArgumentException($result['message']);
|
||||
}
|
||||
|
||||
return $descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ProviderIdentityContextMetadata>
|
||||
*/
|
||||
public function contextualIdentityDetailsForConnection(ProviderConnection $connection): array
|
||||
{
|
||||
return $this->contextualIdentityDetails(
|
||||
provider: trim((string) $connection->provider),
|
||||
scopeIdentifier: trim((string) $connection->entra_tenant_id),
|
||||
providerSpecificIdentity: [
|
||||
'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function auditMetadataForConnection(ProviderConnection $connection, array $extra = []): array
|
||||
{
|
||||
$summary = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
|
||||
return array_merge([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'provider' => (string) $connection->provider,
|
||||
'target_scope' => $summary->targetScope->toArray(),
|
||||
'provider_identity_context' => array_map(
|
||||
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
|
||||
$summary->contextualIdentityDetails,
|
||||
),
|
||||
], $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $fields
|
||||
* @return list<string>
|
||||
*/
|
||||
public function auditFieldNames(array $fields): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (string $field): string => $field === 'entra_tenant_id' ? 'target_scope_identifier' : $field,
|
||||
$fields,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $providerSpecificIdentity
|
||||
* @return list<ProviderIdentityContextMetadata>
|
||||
*/
|
||||
private function contextualIdentityDetails(string $provider, string $scopeIdentifier, array $providerSpecificIdentity = []): array
|
||||
{
|
||||
if ($provider !== 'microsoft') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$details = [
|
||||
ProviderIdentityContextMetadata::microsoftTenantId(
|
||||
$providerSpecificIdentity['microsoft_tenant_id'] ?? $scopeIdentifier,
|
||||
),
|
||||
];
|
||||
|
||||
return array_values(array_filter(
|
||||
$details,
|
||||
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status: string,
|
||||
* provider: string,
|
||||
* scope_kind: string,
|
||||
* failure_code: string,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
private function blocked(string $provider, string $scopeKind, string $failureCode, string $message): array
|
||||
{
|
||||
return [
|
||||
'status' => self::STATUS_BLOCKED,
|
||||
'provider' => $provider,
|
||||
'scope_kind' => $scopeKind,
|
||||
'failure_code' => $failureCode,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Providers\TargetScope;
|
||||
|
||||
final class ProviderIdentityContextMetadata
|
||||
{
|
||||
public const string VISIBILITY_CONTEXTUAL_ONLY = 'contextual_only';
|
||||
|
||||
public const string VISIBILITY_AUDIT_ONLY = 'audit_only';
|
||||
|
||||
public const string VISIBILITY_TROUBLESHOOTING_ONLY = 'troubleshooting_only';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $provider,
|
||||
public readonly string $detailKey,
|
||||
public readonly string $detailLabel,
|
||||
public readonly string $detailValue,
|
||||
public readonly string $visibility = self::VISIBILITY_CONTEXTUAL_ONLY,
|
||||
) {}
|
||||
|
||||
public static function microsoftTenantId(?string $value, string $visibility = self::VISIBILITY_CONTEXTUAL_ONLY): ?self
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
provider: 'microsoft',
|
||||
detailKey: 'microsoft_tenant_id',
|
||||
detailLabel: 'Microsoft tenant ID',
|
||||
detailValue: $value,
|
||||
visibility: $visibility,
|
||||
);
|
||||
}
|
||||
|
||||
public static function authorityTenant(?string $value, string $visibility = self::VISIBILITY_TROUBLESHOOTING_ONLY): ?self
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
provider: 'microsoft',
|
||||
detailKey: 'authority_tenant',
|
||||
detailLabel: 'Authority tenant',
|
||||
detailValue: $value,
|
||||
visibility: $visibility,
|
||||
);
|
||||
}
|
||||
|
||||
public static function redirectUri(?string $value, string $visibility = self::VISIBILITY_TROUBLESHOOTING_ONLY): ?self
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
provider: 'microsoft',
|
||||
detailKey: 'redirect_uri',
|
||||
detailLabel: 'Redirect URI',
|
||||
detailValue: $value,
|
||||
visibility: $visibility,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* provider: string,
|
||||
* detail_key: string,
|
||||
* detail_label: string,
|
||||
* detail_value: string,
|
||||
* visibility: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'provider' => $this->provider,
|
||||
'detail_key' => $this->detailKey,
|
||||
'detail_label' => $this->detailLabel,
|
||||
'detail_value' => $this->detailValue,
|
||||
'visibility' => $this->visibility,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -20,7 +20,7 @@
|
||||
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
|
||||
],
|
||||
'covered_types' => [
|
||||
'baseline.capture' => [
|
||||
'baseline_capture' => [
|
||||
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
|
||||
'queued_stale_after_seconds' => 600,
|
||||
'running_stale_after_seconds' => 1800,
|
||||
@ -28,7 +28,7 @@
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'baseline.compare' => [
|
||||
'baseline_compare' => [
|
||||
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
|
||||
'queued_stale_after_seconds' => 600,
|
||||
'running_stale_after_seconds' => 1800,
|
||||
@ -36,7 +36,7 @@
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'inventory.sync' => [
|
||||
'inventory_sync' => [
|
||||
'job_class' => \App\Jobs\RunInventorySyncJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1200,
|
||||
@ -52,7 +52,15 @@
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'directory.groups.sync' => [
|
||||
'policy.sync_one' => [
|
||||
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 180,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'entra_group_sync' => [
|
||||
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
@ -60,7 +68,7 @@
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'directory.role_definitions.sync' => [
|
||||
'directory_role_definitions.sync' => [
|
||||
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
@ -76,7 +84,7 @@
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'backup.schedule.execute' => [
|
||||
'backup_schedule_run' => [
|
||||
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1200,
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
|
||||
it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
|
||||
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
|
||||
@ -27,31 +25,4 @@
|
||||
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
|
||||
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
|
||||
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture');
|
||||
});
|
||||
|
||||
it('guards canonical operation type writers against raw alias reintroduction', function (): void {
|
||||
$legacyAliases = [
|
||||
'baseline_capture',
|
||||
'baseline_compare',
|
||||
'inventory_sync',
|
||||
'entra_group_sync',
|
||||
'backup_schedule_run',
|
||||
'backup_schedule_retention',
|
||||
'backup_schedule_purge',
|
||||
'directory_role_definitions.sync',
|
||||
];
|
||||
|
||||
$registry = app(ProviderOperationRegistry::class);
|
||||
$registryTypes = array_merge(
|
||||
array_keys($registry->definitions()),
|
||||
collect($registry->definitions())->pluck('operation_type')->all(),
|
||||
array_keys($registry->providerBindings()),
|
||||
collect($registry->providerBindings())
|
||||
->flatMap(static fn (array $bindings): array => collect($bindings)->pluck('operation_type')->all())
|
||||
->all(),
|
||||
OperationRunType::values(),
|
||||
array_keys(config('tenantpilot.operations.lifecycle.covered_types', [])),
|
||||
);
|
||||
|
||||
expect(array_values(array_intersect($legacyAliases, $registryTypes)))->toBe([]);
|
||||
});
|
||||
});
|
||||
@ -2,14 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -67,55 +63,3 @@
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('records provider connection create audits with neutral target-scope metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateProviderConnection::class)
|
||||
->fillForm([
|
||||
'display_name' => 'Audit target scope connection',
|
||||
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
|
||||
'is_default' => true,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('display_name', 'Audit target scope connection')
|
||||
->firstOrFail();
|
||||
|
||||
$log = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'provider_connection.created')
|
||||
->where('resource_id', (string) $connection->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
$metadata = is_array($log->metadata) ? $log->metadata : [];
|
||||
|
||||
expect($metadata)->toHaveKeys([
|
||||
'provider_connection_id',
|
||||
'provider',
|
||||
'target_scope',
|
||||
'provider_identity_context',
|
||||
'connection_type',
|
||||
])
|
||||
->and($metadata)->not->toHaveKey('entra_tenant_id')
|
||||
->and($metadata['target_scope'])->toMatchArray([
|
||||
'provider' => 'microsoft',
|
||||
'scope_kind' => 'tenant',
|
||||
'scope_identifier' => '88888888-8888-8888-8888-888888888888',
|
||||
'shared_label' => 'Target scope',
|
||||
])
|
||||
->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([
|
||||
'provider' => 'microsoft',
|
||||
'detail_key' => 'microsoft_tenant_id',
|
||||
'detail_label' => 'Microsoft tenant ID',
|
||||
'detail_value' => '88888888-8888-8888-8888-888888888888',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
$retentionRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->id)
|
||||
->where('type', 'backup.schedule.retention')
|
||||
->where('type', 'backup_schedule_retention')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())->toBe(1);
|
||||
|
||||
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
||||
@ -45,7 +45,7 @@
|
||||
Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool {
|
||||
return $job->backupScheduleId !== null
|
||||
&& $job->operationRun?->tenant_id === $tenant->getKey()
|
||||
&& $job->operationRun?->type === 'backup.schedule.execute';
|
||||
&& $job->operationRun?->type === 'backup_schedule_run';
|
||||
});
|
||||
});
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
$operationRunService->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: 'backup.schedule.execute',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
@ -94,7 +94,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())->toBe(1);
|
||||
|
||||
$schedule->refresh();
|
||||
@ -133,7 +133,7 @@
|
||||
->and($result['scanned_schedules'])->toBe(0)
|
||||
->and(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())->toBe(0);
|
||||
|
||||
Bus::assertNotDispatched(RunBackupScheduleJob::class);
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
|
||||
$operationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->first();
|
||||
|
||||
expect($operationRun)->not->toBeNull();
|
||||
@ -96,7 +96,7 @@
|
||||
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
@ -133,7 +133,7 @@
|
||||
|
||||
$operationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->first();
|
||||
|
||||
expect($operationRun)->not->toBeNull();
|
||||
@ -180,7 +180,7 @@
|
||||
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
@ -226,7 +226,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('type', ['backup.schedule.execute'])
|
||||
->whereIn('type', ['backup_schedule_run', 'backup_schedule_run'])
|
||||
->count())
|
||||
->toBe(0);
|
||||
});
|
||||
@ -270,13 +270,13 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('user_id')
|
||||
->unique()
|
||||
->values()
|
||||
@ -326,13 +326,13 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('user_id')
|
||||
->unique()
|
||||
->values()
|
||||
@ -382,7 +382,7 @@
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$existing = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup.schedule.execute',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $scheduleA->getKey(),
|
||||
'nonce' => 'existing',
|
||||
@ -403,7 +403,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup.schedule.execute')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())
|
||||
->toBe(3);
|
||||
|
||||
|
||||
@ -129,12 +129,12 @@
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->where('type', 'backup.schedule.purge')
|
||||
->where('type', 'backup_schedule_purge')
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$purgeRun = OperationRun::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->where('type', 'backup.schedule.purge')
|
||||
->where('type', 'backup_schedule_purge')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'directory.groups.sync')
|
||||
->where('type', 'entra_group_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'directory.groups.sync')
|
||||
->where('type', 'entra_group_sync')
|
||||
->where('context->slot_key', $slotKey)
|
||||
->first();
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'directory.groups.sync')
|
||||
->where('type', 'entra_group_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -203,33 +203,6 @@ function operationRunFilterIndicatorLabels($component): array
|
||||
->assertCanNotSeeTableRecords([$otherRun]);
|
||||
});
|
||||
|
||||
it('shows one canonical operation filter option for current and historical inventory values', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
foreach (['inventory.sync', 'inventory_sync', 'provider.inventory.sync'] as $type) {
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(Operations::class);
|
||||
|
||||
/** @var SelectFilter|null $filter */
|
||||
$filter = $component->instance()->getTable()->getFilter('type');
|
||||
|
||||
expect($filter)->not->toBeNull();
|
||||
expect($filter?->getOptions())->toBe([
|
||||
'inventory.sync' => 'Inventory sync',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -58,18 +58,15 @@
|
||||
->all();
|
||||
|
||||
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
|
||||
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
|
||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($visibleColumnNames)->toContain('provider', 'target_scope', 'is_enabled', 'consent_status', 'verification_status');
|
||||
expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status');
|
||||
expect($visibleColumnNames)->not->toContain('status');
|
||||
expect($visibleColumnNames)->not->toContain('health_status');
|
||||
expect($visibleColumnNames)->not->toContain('entra_tenant_id');
|
||||
expect($table->getColumn('status'))->toBeNull();
|
||||
expect($table->getColumn('health_status'))->toBeNull();
|
||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse();
|
||||
expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope');
|
||||
expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
|
||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('migration_review_required'))->not->toBeNull();
|
||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||
|
||||
@ -35,24 +35,6 @@
|
||||
->assertDontSee('Unauthorized Tenant Connection');
|
||||
});
|
||||
|
||||
test('non-members cannot reach provider connection detail target-scope metadata', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Hidden Scope Connection',
|
||||
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('members without capability see provider connection actions disabled with standard tooltip', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||
@ -117,26 +99,3 @@
|
||||
->assertActionVisible('edit')
|
||||
->assertActionEnabled('edit');
|
||||
});
|
||||
|
||||
test('sensitive provider connection mutations remain confirmation and capability gated', function (): void {
|
||||
$source = (string) file_get_contents(repo_path('apps/platform/app/Filament/Resources/ProviderConnectionResource.php'));
|
||||
|
||||
foreach ([
|
||||
'makeSetDefaultAction',
|
||||
'makeEnableDedicatedOverrideAction',
|
||||
'makeRotateDedicatedCredentialAction',
|
||||
'makeDeleteDedicatedCredentialAction',
|
||||
'makeRevertToPlatformAction',
|
||||
'makeEnableConnectionAction',
|
||||
'makeDisableConnectionAction',
|
||||
] as $method) {
|
||||
$start = strpos($source, 'public static function '.$method);
|
||||
expect($start)->not->toBeFalse();
|
||||
|
||||
$next = strpos($source, "\n public static function ", $start + 1);
|
||||
$block = substr($source, $start, $next === false ? null : $next - $start);
|
||||
|
||||
expect($block)->toContain('->requiresConfirmation()')
|
||||
->and($block)->toContain('->requireCapability(');
|
||||
}
|
||||
});
|
||||
|
||||
@ -166,21 +166,19 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
||||
expect($table->persistsSearchInSession())->toBeTrue();
|
||||
expect($table->persistsSortInSession())->toBeTrue();
|
||||
expect($table->persistsFiltersInSession())->toBeTrue();
|
||||
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
|
||||
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.');
|
||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
||||
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.');
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('provider')?->isToggleable())->toBeFalse();
|
||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse();
|
||||
expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope');
|
||||
expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
|
||||
expect($table->getColumn('provider')?->isToggleable())->toBeTrue();
|
||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('entra_tenant_id')?->isToggleable())->toBeTrue();
|
||||
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('last_error_reason_code')?->isToggleable())->toBeTrue();
|
||||
expect($table->getColumn('last_error_reason_code')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('last_error_message')?->isToggleable())->toBeTrue();
|
||||
expect($table->getColumn('last_error_message')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('standardizes the findings list around open triage work with hidden forensic detail', function (): void {
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
use App\Support\OperationRunLinks;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
@ -159,10 +158,3 @@ function operationRunLinkContractViolations(array $paths, array $allowlist = [])
|
||||
->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view')
|
||||
->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper');
|
||||
})->group('surface-guard');
|
||||
|
||||
it('canonicalizes operation type query parameters for operation collection links', function (): void {
|
||||
$url = OperationRunLinks::index(operationType: 'inventory_sync');
|
||||
|
||||
expect($url)->toContain('inventory.sync')
|
||||
->not->toContain('inventory_sync');
|
||||
})->group('surface-guard');
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('blocks Microsoft-specific default labels and audit prose on shared provider connection surfaces', function (): void {
|
||||
$root = repo_path('apps/platform');
|
||||
$paths = [
|
||||
'app/Filament/Resources/ProviderConnectionResource.php',
|
||||
'app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php',
|
||||
'app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php',
|
||||
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php',
|
||||
'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
|
||||
'app/Services/Providers/ProviderConnectionMutationService.php',
|
||||
'app/Services/Verification/StartVerification.php',
|
||||
'app/Jobs/ProviderConnectionHealthCheckJob.php',
|
||||
];
|
||||
|
||||
$forbiddenFragments = [
|
||||
'Entra tenant ID',
|
||||
'Entra Tenant ID',
|
||||
'Directory (tenant) ID',
|
||||
'No Microsoft connections found',
|
||||
'Graph API calls',
|
||||
"'entra_tenant_id' => \$record->entra_tenant_id",
|
||||
"'entra_tenant_id' => (string) \$connection->entra_tenant_id",
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$contents = (string) file_get_contents($root.'/'.$path);
|
||||
|
||||
foreach ($forbiddenFragments as $fragment) {
|
||||
expect($contents)
|
||||
->not->toContain($fragment, sprintf('%s still contains shared-surface provider-specific default prose [%s].', $path, $fragment));
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -57,7 +57,7 @@ function providerDispatchGateSlice(string $source, string $startAnchor, ?string
|
||||
'end' => 'public function sync(',
|
||||
'required' => [
|
||||
'return $this->providerStarts->start(',
|
||||
'operationType: OperationRunType::DirectoryGroupsSync->value',
|
||||
"operationType: 'entra_group_sync'",
|
||||
'EntraGroupSyncJob::dispatch(',
|
||||
'->afterCommit()',
|
||||
],
|
||||
@ -69,7 +69,7 @@ function providerDispatchGateSlice(string $source, string $startAnchor, ?string
|
||||
'end' => 'public function sync(',
|
||||
'required' => [
|
||||
'return $this->providerStarts->start(',
|
||||
'operationType: OperationRunType::DirectoryRoleDefinitionsSync->value',
|
||||
"operationType: 'directory_role_definitions.sync'",
|
||||
'SyncRoleDefinitionsJob::dispatch(',
|
||||
'->afterCommit()',
|
||||
],
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -101,7 +101,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -132,7 +132,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -189,7 +189,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -216,7 +216,7 @@
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->where('type', 'inventory.sync')->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->where('type', 'inventory_sync')->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -234,7 +234,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$existing = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'inventory.sync',
|
||||
type: 'inventory_sync',
|
||||
identityInputs: [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
],
|
||||
@ -253,7 +253,7 @@
|
||||
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('disables inventory sync start action for readonly users', function () {
|
||||
@ -268,5 +268,5 @@
|
||||
->assertActionDisabled('run_inventory_sync');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -116,7 +116,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
||||
|
||||
$opRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'inventory.sync',
|
||||
type: 'inventory_sync',
|
||||
identityInputs: [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
],
|
||||
@ -617,7 +617,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
||||
];
|
||||
|
||||
$hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection);
|
||||
$lock = Cache::lock("inventory.sync:tenant:{$tenant->id}:selection:{$hash}", 900);
|
||||
$lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900);
|
||||
expect($lock->get())->toBeTrue();
|
||||
|
||||
$run = executeInventorySyncNow($tenant, $selection);
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
@ -11,7 +10,6 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
@ -101,7 +99,7 @@
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders neutral tenant id placeholder guidance for onboarding input', function (): void {
|
||||
it('renders the Entra tenant id placeholder for onboarding input guidance', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -116,39 +114,9 @@
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Tenant ID (GUID)')
|
||||
->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false);
|
||||
});
|
||||
|
||||
it('uses target-scope wording in the onboarding provider setup step', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$entraTenantId = '34343434-3434-3434-3434-343434343434';
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantOnboardingWizard::class)
|
||||
->call('identifyManagedTenant', [
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'environment' => 'prod',
|
||||
'name' => 'Target Scope Tenant',
|
||||
])
|
||||
->set('data.connection_mode', 'new')
|
||||
->assertSee('Target scope ID')
|
||||
->assertSee('The provider connection will point to this tenant target scope.')
|
||||
->assertSee($entraTenantId)
|
||||
->assertDontSee('Directory (tenant) ID')
|
||||
->assertDontSee('Graph API calls');
|
||||
});
|
||||
|
||||
it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -227,7 +195,7 @@
|
||||
->assertSuccessful()
|
||||
->assertSee('Skipped - No bootstrap actions selected')
|
||||
->assertSee('Tenant status will be set to Active.')
|
||||
->assertSee('The provider connection will be used for provider API calls.');
|
||||
->assertSee('The provider connection will be used for all Graph API calls.');
|
||||
});
|
||||
|
||||
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {
|
||||
@ -386,7 +354,7 @@
|
||||
$bootstrapRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'context' => [
|
||||
@ -522,7 +490,7 @@
|
||||
|
||||
$session->refresh();
|
||||
|
||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
});
|
||||
|
||||
it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void {
|
||||
@ -620,12 +588,12 @@
|
||||
'restore.execute',
|
||||
'entra_group_sync',
|
||||
'directory_role_definitions.sync',
|
||||
]))->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||
]))->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
$optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions');
|
||||
$optionsMethod->setAccessible(true);
|
||||
|
||||
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
});
|
||||
|
||||
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
|
||||
@ -1466,7 +1434,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(OperationRun::query()
|
||||
@ -1477,17 +1445,9 @@
|
||||
$session->refresh();
|
||||
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
||||
expect($runs)->toBeArray();
|
||||
expect($runs['inventory.sync'] ?? null)->toBeInt();
|
||||
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
||||
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
|
||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::ManagedTenantOnboardingBootstrapStarted->value)
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
expect(data_get($audit->metadata, 'operation_types'))->toBe(['inventory.sync', 'compliance.snapshot'])
|
||||
->and(data_get($audit->metadata, 'started_operation_type'))->toBe('inventory.sync');
|
||||
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||
});
|
||||
|
||||
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
|
||||
@ -1543,11 +1503,11 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
|
||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
$inventoryRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
@ -1556,7 +1516,7 @@
|
||||
'outcome' => 'succeeded',
|
||||
])->save();
|
||||
|
||||
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
|
||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
|
||||
@ -1568,7 +1528,7 @@
|
||||
|
||||
$session->refresh();
|
||||
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
||||
expect($runs['inventory.sync'] ?? null)->toBeInt();
|
||||
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
||||
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
|
||||
});
|
||||
|
||||
@ -1602,7 +1562,7 @@
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'inventory.sync',
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()),
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Evidence\Sources\OperationsSummarySource;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Audit\AuditOutcome;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -17,7 +15,7 @@
|
||||
|
||||
$run = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'inventory.sync',
|
||||
type: 'inventory_sync',
|
||||
identityInputs: ['selection_hash' => 'abc123'],
|
||||
context: ['selection_hash' => 'abc123'],
|
||||
initiator: $user,
|
||||
@ -48,7 +46,7 @@
|
||||
->and((string) $audit?->resource_id)->toBe((string) $run->getKey())
|
||||
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Success)
|
||||
->and($audit?->actorDisplayLabel())->toBe($user->name)
|
||||
->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory.sync');
|
||||
->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory_sync');
|
||||
});
|
||||
|
||||
it('writes blocked terminal audit semantics for blocked runs', function (): void {
|
||||
@ -82,23 +80,3 @@
|
||||
->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Blocked)
|
||||
->and(data_get($audit?->metadata, 'failure_summary.0.reason_code'))->toBe('intune_rbac.not_configured');
|
||||
});
|
||||
|
||||
it('emits canonical operation types in operations evidence summaries for historical rows', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$payload = app(OperationsSummarySource::class)->collect($tenant);
|
||||
$entry = data_get($payload, 'summary_payload.entries.0');
|
||||
|
||||
expect($entry)->toBeArray()
|
||||
->and($entry['operation_type'] ?? null)->toBe('inventory.sync')
|
||||
->and($entry['type'] ?? null)->toBe('inventory_sync');
|
||||
});
|
||||
|
||||
@ -74,7 +74,7 @@ function () use (&$terminalInvoked): string {
|
||||
->and($run->context['reason_code'] ?? null)->toBe('missing_capability')
|
||||
->and($run->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN);
|
||||
})->with([
|
||||
'provider inventory sync' => ['inventory.sync', ProviderInventorySyncJob::class],
|
||||
'provider inventory sync' => ['inventory_sync', ProviderInventorySyncJob::class],
|
||||
'provider compliance snapshot' => ['compliance.snapshot', ProviderComplianceSnapshotJob::class],
|
||||
]);
|
||||
|
||||
@ -139,7 +139,7 @@ function () use (&$terminalInvoked): string {
|
||||
|
||||
$run = app(OperationRunService::class)->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: 'backup.schedule.execute',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||
'scheduled_for' => now()->toDateTimeString(),
|
||||
@ -190,7 +190,7 @@ function () use (&$terminalInvoked): string {
|
||||
|
||||
$scheduleRun = app(OperationRunService::class)->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: 'backup.schedule.execute',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => 99,
|
||||
'scheduled_for' => now()->toDateTimeString(),
|
||||
@ -211,7 +211,7 @@ function () use (&$terminalInvoked): string {
|
||||
$result = app(ProviderOperationStartGate::class)->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'inventory.sync',
|
||||
operationType: 'inventory_sync',
|
||||
dispatcher: static fn (OperationRun $run): null => null,
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
@ -43,7 +43,7 @@ function runQueuedInventoryJobThroughMiddleware(object $job, Closure $terminal):
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -15,9 +15,3 @@
|
||||
expect(OperationCatalog::label('rbac.health_check'))
|
||||
->toBe('RBAC health check');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not normalize unknown values to nearby canonical operation labels', function (): void {
|
||||
expect(OperationCatalog::label('inventory-sync'))
|
||||
->toBe('Unknown operation')
|
||||
->not->toBe(OperationCatalog::label('inventory.sync'));
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders create and edit flows with neutral target-scope labels', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Neutral connection',
|
||||
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ProviderConnectionResource::getUrl('create', ['tenant_id' => $tenant->external_id], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Target scope ID')
|
||||
->assertSee('Target scope')
|
||||
->assertDontSee('Entra tenant ID');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Target scope ID')
|
||||
->assertSee('Target scope')
|
||||
->assertDontSee('Entra tenant ID');
|
||||
});
|
||||
|
||||
it('keeps list and detail surfaces default-visible around provider target scope consent and verification', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Scope-visible connection',
|
||||
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||
'consent_status' => 'granted',
|
||||
'verification_status' => 'healthy',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ListProviderConnections::class);
|
||||
$table = $component->instance()->getTable();
|
||||
$visibleColumnNames = collect($table->getVisibleColumns())
|
||||
->map(fn ($column): string => $column->getName())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status')
|
||||
->and($visibleColumnNames)->not->toContain('entra_tenant_id')
|
||||
->and($table->getColumn('target_scope')?->getLabel())->toBe('Target scope')
|
||||
->and($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Target scope')
|
||||
->assertSee('Provider identity details')
|
||||
->assertSee('Microsoft tenant ID')
|
||||
->assertSee('Consent')
|
||||
->assertSee('Verification')
|
||||
->assertDontSee('Entra tenant ID');
|
||||
});
|
||||
|
||||
it('uses neutral validation attributes when the create flow misses target-scope context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateProviderConnection::class)
|
||||
->fillForm([
|
||||
'display_name' => 'Missing target scope',
|
||||
'entra_tenant_id' => '',
|
||||
'is_default' => true,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['entra_tenant_id' => 'required']);
|
||||
});
|
||||
|
||||
it('blocks unsupported provider target-scope combinations before provider execution', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'contoso',
|
||||
'display_name' => 'Unsupported provider connection',
|
||||
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$resolution = app(ProviderConnectionResolver::class)
|
||||
->validateConnection($tenant, 'contoso', $connection->fresh(['tenant']));
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($resolution->resolved)->toBeFalse()
|
||||
->and($resolution->reasonCode)->toBe('provider_binding_unsupported')
|
||||
->and($resolution->extensionReasonCode)->toBe('ext.connection_scope_unsupported')
|
||||
->and($resolution->message)->toBe('This provider and target-scope combination is not supported.');
|
||||
});
|
||||
@ -38,14 +38,11 @@
|
||||
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Spec081 Connection')
|
||||
->assertSee('Target scope')
|
||||
->assertSee('Target scope ID')
|
||||
->assertSee('Lifecycle')
|
||||
->assertSee('Enabled')
|
||||
->assertSee('Verification')
|
||||
->assertSee('Migration review')
|
||||
->assertSee('Review required')
|
||||
->assertDontSee('Entra tenant ID')
|
||||
->assertDontSee('Diagnostic status')
|
||||
->assertDontSee('Diagnostic health');
|
||||
});
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
|
||||
$inventoryRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->count())->toBe(1);
|
||||
|
||||
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
||||
@ -96,7 +96,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -189,7 +189,7 @@
|
||||
|
||||
$inventoryRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'inventory.sync',
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()),
|
||||
@ -141,7 +141,7 @@
|
||||
|
||||
$inventoryRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
@ -155,7 +155,7 @@
|
||||
'outcome' => 'succeeded',
|
||||
])->save();
|
||||
|
||||
$component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']);
|
||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
|
||||
@ -39,19 +39,12 @@
|
||||
$blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked');
|
||||
expect($blocked->color)->toBe('danger');
|
||||
expect($blocked->label)->toBe('Blocked');
|
||||
|
||||
|
||||
$degraded = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'degraded');
|
||||
expect($degraded->color)->toBe('warning');
|
||||
expect($degraded->label)->toBe('Degraded');
|
||||
});
|
||||
|
||||
it('does not reuse consent labels for provider verification summaries', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)->toBe('Required')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label)->toBe('Pending')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)
|
||||
->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label);
|
||||
});
|
||||
|
||||
it('does not expose legacy provider status badge domains anymore', function (): void {
|
||||
$domainValues = collect(BadgeDomain::cases())
|
||||
->map(fn (BadgeDomain $domain): string => $domain->value)
|
||||
|
||||
@ -197,38 +197,3 @@
|
||||
|
||||
expect($drafts->modelKeys())->toBe([(int) $unlinkedDraft->getKey()]);
|
||||
});
|
||||
|
||||
it('resolves drafts that still contain legacy bootstrap operation state for read-side normalization', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
'workspace' => $workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
|
||||
'bootstrap_operation_runs' => ['inventory_sync' => 12345],
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$resolved = app(OnboardingDraftResolver::class)->resolve($draft->getKey(), $user, $workspace);
|
||||
|
||||
expect($resolved->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot'])
|
||||
->and($resolved->state['bootstrap_operation_runs'] ?? null)->toBe(['inventory_sync' => 12345]);
|
||||
});
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
'execution_prerequisites' => 'not_applicable',
|
||||
])
|
||||
->and($decision->toArray())->toMatchArray([
|
||||
'operation_type' => 'inventory.sync',
|
||||
'operation_type' => 'inventory_sync',
|
||||
'authority_mode' => 'actor_bound',
|
||||
'allowed' => true,
|
||||
'retryable' => false,
|
||||
|
||||
@ -18,13 +18,6 @@
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked');
|
||||
});
|
||||
|
||||
it('keeps consent and verification badge domains distinct for provider connection summaries', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)->toBe('Granted')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label)->toBe('Healthy')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)
|
||||
->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label);
|
||||
});
|
||||
|
||||
it('maps managed-tenant onboarding verification badge aliases consistently', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'unknown')->label)->toBe('Not started')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'healthy')->label)->toBe('Ready')
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('normalizes provider connections into neutral target-scope descriptors with contextual Microsoft metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'display_name' => 'Primary connection',
|
||||
]);
|
||||
|
||||
$descriptor = app(ProviderConnectionTargetScopeNormalizer::class)
|
||||
->descriptorForConnection($connection->fresh(['tenant']));
|
||||
$summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant']));
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($descriptor->provider)->toBe('microsoft')
|
||||
->and($descriptor->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
|
||||
->and($descriptor->scopeIdentifier)->toBe('11111111-1111-1111-1111-111111111111')
|
||||
->and($descriptor->sharedLabel)->toBe('Target scope')
|
||||
->and($descriptor->summary())->toContain((string) $tenant->name)
|
||||
->and($summary->targetScopeSummary())->toContain('11111111-1111-1111-1111-111111111111')
|
||||
->and($summary->contextualIdentityDetails)->toHaveCount(1)
|
||||
->and($summary->contextualIdentityDetails[0]->detailLabel)->toBe('Microsoft tenant ID');
|
||||
});
|
||||
|
||||
it('blocks unsupported provider-scope combinations explicitly instead of inheriting Microsoft defaults', function (): void {
|
||||
$result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'unknown-provider',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: 'scope-1',
|
||||
scopeDisplayName: 'Scope 1',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED)
|
||||
->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION)
|
||||
->and($result['message'])->toContain('not supported');
|
||||
});
|
||||
|
||||
it('blocks missing target-scope context with neutral validation language', function (): void {
|
||||
$result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||
provider: 'microsoft',
|
||||
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||
scopeIdentifier: '',
|
||||
scopeDisplayName: 'Missing scope',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED)
|
||||
->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT)
|
||||
->and($result['message'])->toBe('A target scope identifier is required for this provider connection.');
|
||||
});
|
||||
@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Providers\ProviderIdentityResolver;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('exposes neutral target-scope truth beside provider-owned identity metadata', function (): void {
|
||||
config()->set('graph.client_id', 'platform-client-id');
|
||||
config()->set('graph.client_secret', 'platform-client-secret');
|
||||
config()->set('graph.tenant_id', 'platform-home-tenant-id');
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
|
||||
]);
|
||||
|
||||
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant']));
|
||||
|
||||
expect($resolution->resolved)->toBeTrue()
|
||||
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
|
||||
->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222')
|
||||
->and($resolution->targetScope)->not->toBeNull()
|
||||
->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
|
||||
->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222')
|
||||
->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all())
|
||||
->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri');
|
||||
});
|
||||
|
||||
it('keeps dedicated runtime credentials out of the shared target-scope descriptor', function (): void {
|
||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'payload' => [
|
||||
'client_id' => 'dedicated-client-id',
|
||||
'client_secret' => 'dedicated-client-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential']));
|
||||
|
||||
expect($resolution->resolved)->toBeTrue()
|
||||
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_id')
|
||||
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret')
|
||||
->and($resolution->effectiveClientId)->toBe('dedicated-client-id');
|
||||
});
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
use App\Support\OperationCatalog;
|
||||
|
||||
it('declares provider operation definitions with canonical operation types only', function (): void {
|
||||
$registry = app(ProviderOperationRegistry::class);
|
||||
|
||||
foreach ($registry->definitions() as $key => $definition) {
|
||||
$operationType = (string) ($definition['operation_type'] ?? '');
|
||||
$resolution = OperationCatalog::resolve($operationType);
|
||||
|
||||
expect($key)->toBe($operationType)
|
||||
->and($resolution->aliasStatus)->toBe('canonical')
|
||||
->and($resolution->canonical->canonicalCode)->toBe($operationType);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares provider bindings with canonical operation types only', function (): void {
|
||||
$registry = app(ProviderOperationRegistry::class);
|
||||
|
||||
foreach ($registry->providerBindings() as $key => $bindings) {
|
||||
expect(OperationCatalog::resolve($key)->aliasStatus)->toBe('canonical');
|
||||
|
||||
foreach ($bindings as $binding) {
|
||||
$operationType = (string) ($binding['operation_type'] ?? '');
|
||||
$resolution = OperationCatalog::resolve($operationType);
|
||||
|
||||
expect($operationType)->toBe($key)
|
||||
->and($resolution->aliasStatus)->toBe('canonical')
|
||||
->and($resolution->canonical->canonicalCode)->toBe($operationType);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects legacy aliases as provider registry keys', function (): void {
|
||||
$registry = app(ProviderOperationRegistry::class);
|
||||
|
||||
expect($registry->isAllowed('inventory_sync'))->toBeFalse()
|
||||
->and($registry->isAllowed('entra_group_sync'))->toBeFalse()
|
||||
->and($registry->isAllowed('directory_role_definitions.sync'))->toBeFalse()
|
||||
->and($registry->isAllowed('inventory.sync'))->toBeTrue()
|
||||
->and($registry->isAllowed('directory.groups.sync'))->toBeTrue()
|
||||
->and($registry->isAllowed('directory.role_definitions.sync'))->toBeTrue();
|
||||
});
|
||||
@ -110,7 +110,7 @@
|
||||
|
||||
$blocking = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
@ -221,11 +221,11 @@
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'directory.groups.sync',
|
||||
operationType: 'entra_group_sync',
|
||||
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
||||
$dispatched++;
|
||||
|
||||
expect($run->type)->toBe('directory.groups.sync');
|
||||
expect($run->type)->toBe('entra_group_sync');
|
||||
},
|
||||
);
|
||||
|
||||
@ -257,7 +257,7 @@
|
||||
|
||||
$blocking = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'running',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
@ -281,28 +281,6 @@
|
||||
expect($result->run->getKey())->toBe($blocking->getKey());
|
||||
});
|
||||
|
||||
it('rejects legacy aliases before starting provider operations', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'directory-entra-tenant-id',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
]);
|
||||
|
||||
$gate = app(ProviderOperationStartGate::class);
|
||||
|
||||
expect(fn () => $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $connection,
|
||||
operationType: 'entra_group_sync',
|
||||
dispatcher: fn () => null,
|
||||
))->toThrow(\InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('blocks provider starts when no explicit provider binding supports the connection provider', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('uses canonical dotted operation codes as enum values', function (): void {
|
||||
expect(OperationRunType::BaselineCapture->value)->toBe('baseline.capture')
|
||||
->and(OperationRunType::BaselineCompare->value)->toBe('baseline.compare')
|
||||
->and(OperationRunType::InventorySync->value)->toBe('inventory.sync')
|
||||
->and(OperationRunType::DirectoryGroupsSync->value)->toBe('directory.groups.sync')
|
||||
->and(OperationRunType::BackupScheduleExecute->value)->toBe('backup.schedule.execute')
|
||||
->and(OperationRunType::BackupScheduleRetention->value)->toBe('backup.schedule.retention')
|
||||
->and(OperationRunType::BackupSchedulePurge->value)->toBe('backup.schedule.purge')
|
||||
->and(OperationRunType::DirectoryRoleDefinitionsSync->value)->toBe('directory.role_definitions.sync');
|
||||
});
|
||||
|
||||
it('keeps enum canonicalCode as a no-op compatibility shim', function (): void {
|
||||
foreach (OperationRunType::cases() as $case) {
|
||||
expect($case->canonicalCode())->toBe($case->value);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not expose legacy raw aliases as enum values', function (): void {
|
||||
expect(OperationRunType::values())->not->toContain(
|
||||
'baseline_capture',
|
||||
'baseline_compare',
|
||||
'inventory_sync',
|
||||
'entra_group_sync',
|
||||
'backup_schedule_run',
|
||||
'backup_schedule_retention',
|
||||
'backup_schedule_purge',
|
||||
'directory_role_definitions.sync',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps every enum value in the operation catalog as current canonical truth', function (): void {
|
||||
foreach (OperationRunType::values() as $type) {
|
||||
$resolution = OperationCatalog::resolve($type);
|
||||
|
||||
expect($resolution->aliasStatus)->toBe('canonical')
|
||||
->and($resolution->canonical->canonicalCode)->toBe($type);
|
||||
}
|
||||
});
|
||||
@ -43,29 +43,6 @@
|
||||
->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update');
|
||||
});
|
||||
|
||||
it('keeps legacy aliases as read-side compatibility only', function (): void {
|
||||
$aliasInventory = OperationCatalog::aliasInventory();
|
||||
|
||||
foreach ($aliasInventory as $rawValue => $metadata) {
|
||||
if (($metadata['alias_status'] ?? null) !== 'legacy_alias') {
|
||||
continue;
|
||||
}
|
||||
|
||||
expect($metadata['write_allowed'] ?? null)
|
||||
->toBeFalse("Legacy alias [{$rawValue}] must not be write-time truth.");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps unknown operation values explicitly unknown', function (): void {
|
||||
$resolution = OperationCatalog::resolve('inventory-sync');
|
||||
|
||||
expect($resolution->canonical->canonicalCode)->toBe('inventory-sync')
|
||||
->and($resolution->canonical->displayLabel)->toBe('Unknown operation')
|
||||
->and($resolution->aliasStatus)->toBe('unknown')
|
||||
->and($resolution->wasLegacyAlias)->toBeFalse()
|
||||
->and($resolution->aliasesConsidered)->toBe([]);
|
||||
});
|
||||
|
||||
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
|
||||
$descriptor = OperationCatalog::ownershipDescriptor();
|
||||
$canonicalInventory = OperationCatalog::canonicalInventory();
|
||||
@ -76,4 +53,4 @@
|
||||
->and($canonicalInventory['baseline.compare']['display_label'] ?? null)->toBe('Baseline compare')
|
||||
->and($aliasInventory['baseline_compare']['canonical_name'] ?? null)->toBe('baseline.compare')
|
||||
->and($aliasInventory['baseline_compare']['retirement_path'] ?? null)->toContain('baseline.compare');
|
||||
});
|
||||
});
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-04-25
|
||||
**Last updated**: 2026-04-24
|
||||
|
||||
---
|
||||
|
||||
@ -66,20 +66,6 @@ ### R1.9 Platform Localization v1 (DE/EN)
|
||||
|
||||
**Active specs**: — (not yet specced)
|
||||
|
||||
### Product Scalability & Self-Service Foundation
|
||||
Self-service and supportability foundation that keeps TenantPilot operable as a low-headcount, AI-assisted SaaS instead of drifting into manual onboarding, manual support, and founder-dependent customer operations.
|
||||
**Goal**: Productize the recurring work around onboarding, diagnostics, support context, plan limits, and customer guidance so that new customers can evaluate, onboard, operate, and request help with minimal manual intervention.
|
||||
|
||||
- Self-Service Tenant Onboarding & Connection Readiness: guided tenant setup, consent readiness, provider connection checks, permission diagnostics, setup progress, completion score, and concrete next actions
|
||||
- Support Diagnostic Pack: diagnostic bundles for workspace, tenant, OperationRun, Finding, ProviderConnection, and report contexts with relevant health state, permissions, run context, errors, audit references, and AI-readable summaries
|
||||
- In-App Support Request with Context: support entry points that attach workspace, tenant, run/finding/report references, severity, diagnostic pack reference, and ticket reference back into TenantPilot
|
||||
- Product Knowledge & Contextual Help: help registry for feature explanations, status meanings, error guidance, permission rationale, troubleshooting hints, and docs links; also usable as the source layer for later AI support
|
||||
- Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes
|
||||
- Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate
|
||||
- Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features
|
||||
|
||||
**Active specs**: — (not yet specced)
|
||||
|
||||
---
|
||||
|
||||
## Planned (Next Quarter)
|
||||
@ -104,7 +90,6 @@ ### R1.x Foundation Hardening — Governance Platform Anti-Drift
|
||||
- Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries
|
||||
- Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth
|
||||
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core
|
||||
- Codebase Quality & Engineering Maturity hardening so the platform remains enterprise-maintainable while the governance surface grows: System Panel least-privilege capabilities, static-analysis baseline, architecture-boundary guard tests, and targeted decomposition of large Filament/service hotspots
|
||||
- No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only
|
||||
|
||||
### R2 Completion — Evidence & Exception Workflows
|
||||
@ -113,9 +98,6 @@ ### R2 Completion — Evidence & Exception Workflows
|
||||
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
|
||||
- Workspace-level PII override for review packs → deferred from 109
|
||||
- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions
|
||||
- Support Diagnostic Pack → connect tenant/review/finding/report/operation contexts into a reusable support bundle before support demand scales
|
||||
- In-App Support Request with Context → attach the relevant diagnostic pack and ticket reference to support workflows without creating a separate support data model
|
||||
- Product Knowledge & Contextual Help → reuse canonical glossary, outcome/reason semantics, and report/finding terminology as the product-help source layer
|
||||
|
||||
### Findings Workflow v2 / Execution Layer
|
||||
Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation.
|
||||
@ -136,57 +118,10 @@ ### Platform Operations Maturity
|
||||
- Raw error/context drilldowns for system console (deferred from Spec 114)
|
||||
- Multi-workspace operator selection in `/system` (deferred from Spec 113)
|
||||
|
||||
### Solo-Founder SaaS Automation & Operating Readiness
|
||||
Internal operating-system track for running TenantPilot as a lean, AI-assisted SaaS company with repeatable customer acquisition, onboarding, support, billing, security review, and release communication.
|
||||
**Goal**: Keep company operations from becoming founder-only manual work while the product moves from pilots to repeatable customer delivery.
|
||||
|
||||
- Lead Capture & CRM Pipeline: website lead forms, demo requests, waitlist, lead status, pilot status, customer status, follow-up reminders, and meeting notes capture
|
||||
- Demo Environment Automation: repeatable demo workspace, sample tenant data, reset flow, demo scripts, and separate demo stories for MSP and enterprise IT buyers
|
||||
- Trial Provisioning Workflow: trial request intake, plan/limit assignment, provisioning checklist, onboarding status, trial expiry, conversion path, and grace handling
|
||||
- Billing & Contract Readiness: plan matrix, offer templates, invoicing flow, payment/billing status, trial-to-paid process, cancellation process, and grace-period handling
|
||||
- AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup
|
||||
- Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation
|
||||
- Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs
|
||||
- Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support
|
||||
- Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates
|
||||
- Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist
|
||||
|
||||
**Active specs**: — (not yet specced; company-ops track, not all items need product specs)
|
||||
|
||||
### Additional Solo-Founder Scale Guardrails
|
||||
Cross-cutting operating guardrails that prevent TenantPilot from scaling through hidden manual work, unclear customer health, missing operational controls, ad-hoc customer communication, or unmanaged founder dependency.
|
||||
**Goal**: Make repeatability, observability, controllability, and delegability explicit before customer volume makes the gaps expensive.
|
||||
|
||||
- Product Usage & Adoption Telemetry: privacy-aware usage signals for onboarding completion, feature adoption, report exports, failed flows, support-triggering surfaces, inactive customers, and trial conversion indicators
|
||||
- Customer Health Score: derived customer/workspace health indicators from login/activity, provider health, last sync, baseline compare freshness, open high findings, overdue SLAs, expiring risk acceptances, failed runs, support load, and review-pack readiness
|
||||
- Operational Controls & Feature Flags: global/workspace kill switches and scoped controls for risky features, restore actions, exports, AI functions, provider actions, trials, maintenance scenarios, and temporary read-only states
|
||||
- Customer Lifecycle Communication: structured lifecycle messages for welcome, onboarding, trial reminders, provider health warnings, review-pack readiness, risk-expiry reminders, release updates, incidents, renewal, payment issues, and churn feedback
|
||||
- Vendor Questionnaire Answer Bank: reusable security/procurement answers aligned with the Security Trust Pack, product data model, Microsoft permissions, hosting, AI usage, subprocessors, retention, backup, deletion, and incident handling
|
||||
- Product Intake & No-Customization Governance: feature-request intake, roadmap-fit classification, no-custom-work policy, customer exception handling, productization rules, and a clear path from request → candidate → spec → release or rejection
|
||||
- Support Severity Matrix & Runbooks: P1–P4 definitions, incident vs support vs bug vs feature request distinction, response expectations by plan, escalation rules, known-issue handling, and internal support runbooks
|
||||
- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility
|
||||
- Business Continuity / Founder Backup Plan: access documentation, secret management, emergency contacts, deployment and restore runbooks, incident templates, DNS/domain/hosting ownership, billing access, and vacation/sickness fallback
|
||||
|
||||
**Active specs**: — (not yet specced; guardrail track, only product-impacting items should become specs)
|
||||
|
||||
---
|
||||
|
||||
## Mid-term (2–3 Quarters)
|
||||
|
||||
### Product Usage, Customer Health & Operational Controls
|
||||
Product-side implementation lane for the highest-impact solo-founder guardrails: adoption telemetry, customer/workspace health scoring, and operator controls/feature flags.
|
||||
**Goal**: Give the founder/operator a measurable, controllable view of customer adoption, risk, and operational safety without relying on manual checks across logs, support tools, billing tools, and product screens.
|
||||
**Why it matters**: Low-headcount SaaS only works if the product shows where customers are stuck, which workspaces are unhealthy, and which features can be safely paused or limited during incidents.
|
||||
**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation.
|
||||
**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice.
|
||||
|
||||
### AI-Assisted Customer Operations
|
||||
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by human approval and product auditability.
|
||||
**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval.
|
||||
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation.
|
||||
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
|
||||
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
|
||||
|
||||
### Decision-Based Operating Foundations
|
||||
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
||||
**Goal**: Prepare TenantPilot for a quieter, decision-centered operating model where primary surfaces ask for action and deeper technical detail stays available on demand.
|
||||
@ -200,24 +135,12 @@ ### MSP Portfolio & Operations (Multi-Tenant)
|
||||
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
|
||||
**Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable.
|
||||
|
||||
### Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the Microsoft-first workspace portfolio, while keeping the decision model provider-extensible for later non-Microsoft domains.
|
||||
**Goal**: Move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the underlying decision model should avoid hard-coding Microsoft-only assumptions where a provider-neutral abstraction is already available.
|
||||
**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable Microsoft tenant governance first, not just a browser for runs, evidence, and tenant state. Detail pages remain available as evidence and diagnostics, but the default operating model becomes guided decisions, not manual investigation.
|
||||
**Depends on**: Decision-Based Operating Foundations, Product Knowledge & Contextual Help, Support Diagnostic Pack, Customer Health Score, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth, operational controls, and human approval gates.
|
||||
**Scope direction**: Start with a Governance Inbox / Action Center, decision items, decision packs, actionable alerts, and approval-gated workflows for Microsoft tenant governance. Later add automation policies, guardrails, maintenance windows, dual approval, before/after evidence automation, and limited remediation execution. Keep human approval and auditability central; avoid blind autopilot remediation.
|
||||
|
||||
**Core workflow**:
|
||||
- Detect relevant governance work automatically
|
||||
- Group, deduplicate, and prioritize related signals
|
||||
- Generate a decision pack with summary, impact, evidence, affected tenants/policies, recommended actions, and confidence
|
||||
- Present clear actions such as approve, reject, snooze, assign, accept risk, create ticket, run compare, generate review pack, or request evidence
|
||||
- Require human approval for tenant-changing, customer-facing, or risk-accepting actions
|
||||
- Execute approved follow-up through OperationRuns or controlled workflows
|
||||
- Verify outcome and attach before/after evidence
|
||||
- Keep audit trail across detection, recommendation, approval, execution, and verification
|
||||
|
||||
**Anti-pattern**: Do not make customers manually troubleshoot by navigating through raw runs, logs, tables, and details as the primary workflow. Raw surfaces are evidence and diagnostics, not the main operating model.
|
||||
### Human-in-the-Loop Autonomous Governance (Decision-Based Operating)
|
||||
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio.
|
||||
**Goal**: Reduce operator work from searching and correlating to approving, rejecting, deferring, or time-boxing deviations while TenantPilot handles the mechanical follow-through.
|
||||
**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable governance, not just a browser for runs, evidence, and tenant state.
|
||||
**Depends on**: Decision-Based Operating Foundations, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth.
|
||||
**Scope direction**: Start with governance inbox + decision packs + actionable alerts. Later add automation policies, guardrails, maintenance windows, dual approval, and before/after evidence automation. Keep human approval and auditability central; avoid blind autopilot remediation.
|
||||
|
||||
### Drift & Change Governance ("Revenue Lever #1")
|
||||
Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection.
|
||||
@ -302,19 +225,9 @@ ## Infrastructure & Platform Debt
|
||||
|
||||
| Item | Risk | Status |
|
||||
|------|------|--------|
|
||||
| No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||
| No product-level entitlement foundation yet | Later pricing, trial, retention, export, user, and tenant limits may require invasive retrofits | Covered by Product Scalability & Self-Service Foundation |
|
||||
| No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation |
|
||||
| No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||
| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No `.env.example` in repo | Onboarding friction | Open |
|
||||
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed |
|
||||
| No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open |
|
||||
| Thin architecture-boundary enforcement | Product tests are strong, but architecture-level guardrails need expansion; covered by `Architecture Boundary Guard Tests` spec candidate | Open |
|
||||
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited | Review needed |
|
||||
| No PHPStan/Larastan | No static analysis | Open |
|
||||
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
|
||||
| No formal release process | Manual deploys | Open |
|
||||
| Dokploy config external to repo | Env drift | Open |
|
||||
@ -323,26 +236,17 @@ ## Infrastructure & Platform Debt
|
||||
|
||||
## Priority Ranking (from Product Brainstorming)
|
||||
|
||||
1. Product Scalability & Self-Service Foundation
|
||||
2. Product Usage, Customer Health & Operational Controls
|
||||
3. Decision-Based Operating / Governance Inbox
|
||||
4. MSP Portfolio + Alerting
|
||||
5. Drift + Approval Workflows
|
||||
6. Evidence / Review Packs + Customer Review Workspace
|
||||
7. Standardization / Linting
|
||||
8. Promotion DEV→PROD
|
||||
9. Recovery Confidence
|
||||
10. Solo-Founder SaaS Automation & Operating Readiness
|
||||
11. Additional Solo-Founder Scale Guardrails
|
||||
1. MSP Portfolio + Alerting
|
||||
2. Drift + Approval Workflows
|
||||
3. Standardization / Linting
|
||||
4. Promotion DEV→PROD
|
||||
5. Recovery Confidence
|
||||
|
||||
---
|
||||
|
||||
## How to use this file
|
||||
|
||||
- **Big product and operating themes** live here.
|
||||
- **Big themes** live here.
|
||||
- **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md)
|
||||
- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates.
|
||||
- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows.
|
||||
- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions.
|
||||
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
|
||||
- **Product principles** → see [principles.md](principles.md)
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates and Additional Solo-Founder Scale Guardrails candidates from roadmap: Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags, Customer Lifecycle Communication, Product Intake & No-Customization Governance, and Data Retention / Export / Deletion Self-Service; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||
**Last reviewed**: 2026-04-24 (added Platform Hardening — OperationRun UX Consistency cluster with OperationRun Start UX Contract, Generic Active Run Surface, OperationRun Notification Lifecycle, and OperationRun Startsurface Migration; promoted Provider Boundary Hardening to Spec 237, clarified the remaining near-term sequencing after Canonical Control Catalog Foundation and Provider Boundary Hardening, and retained `Customer Review Workspace v1` as the customer-facing review consumption candidate that sharpens the R2 read-only/customer review lane)
|
||||
|
||||
---
|
||||
|
||||
@ -24,15 +24,6 @@ ## Inbox
|
||||
- Workspace-level PII override for review packs (deferred from Spec 109 — controls whether PII is included/redacted in tenant review pack exports at workspace scope)
|
||||
- CSV export for filtered run metadata (deferred from Spec 114 — allow operators to export filtered operation run lists from the system console as CSV)
|
||||
- Raw error/context drilldowns for system console (deferred from Spec 114 — in-product drilldown into raw error payloads and execution context for failed/stuck runs in the system console)
|
||||
- Lead Capture & CRM Pipeline (company-ops track; not a product spec unless TenantPilot later needs in-product customer lifecycle surfaces)
|
||||
- Billing & Contract Readiness (company-ops track; product spec only for plan/entitlement/billing-status foundation)
|
||||
- AVV / DPA / TOM / Legal Pack (company-ops track; source artifacts should align with product data model but are not a product feature by default)
|
||||
- Support Desk + AI Triage (company-ops track; product spec only where TenantPilot creates support context bundles or in-app support requests)
|
||||
- Monitoring & Incident Runbooks (company-ops track; product spec only where platform telemetry or customer-facing status integrations are required)
|
||||
- Release & Customer Communication Automation (company-ops track; product spec only where release metadata/changelog becomes in-product)
|
||||
- Vendor Questionnaire Answer Bank (company-ops track; generally not a product spec unless answers become customer-facing trust-center content or product-backed compliance evidence)
|
||||
- Support Severity Matrix & Runbooks (company-ops track; product spec only where severity, SLA, escalation, or incident state becomes modeled in TenantPilot)
|
||||
- Business Continuity / Founder Backup Plan (company-ops track; not a product spec unless product-side operational controls or customer-facing continuity surfaces are required)
|
||||
|
||||
---
|
||||
|
||||
@ -73,762 +64,18 @@ ## Qualified
|
||||
|
||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||
|
||||
|
||||
> **Current strategic priority — Governance Platform Foundation**
|
||||
>
|
||||
> The next promoted specs should stabilize TenantPilot as a Governance-of-Record platform before expanding into additional Microsoft domains, compliance overlays, or multi-cloud execution.
|
||||
>
|
||||
> Recommended next sequence:
|
||||
>
|
||||
> 1. **Self-Service Tenant Onboarding & Connection Readiness**
|
||||
> 2. **Support Diagnostic Pack**
|
||||
> 3. **Product Usage & Adoption Telemetry**
|
||||
> 4. **Operational Controls & Feature Flags**
|
||||
> 5. **Provider Identity & Target Scope Neutrality**
|
||||
> 6. **Canonical Operation Type Source of Truth**
|
||||
> 7. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||
> 8. **Customer Review Workspace v1**
|
||||
> 1. **Provider Identity & Target Scope Neutrality**
|
||||
> 2. **Canonical Operation Type Source of Truth**
|
||||
> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||
> 4. **Customer Review Workspace v1**
|
||||
>
|
||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support and lack of product-side observability/control. Self-service onboarding, diagnostic packs, adoption telemetry, and operational controls therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, and safe to run with low headcount.
|
||||
|
||||
|
||||
> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track.
|
||||
|
||||
### Self-Service Tenant Onboarding & Connection Readiness
|
||||
- **Type**: product scalability / onboarding foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: Tenant onboarding, Microsoft consent readiness, provider connection validation, permission diagnostics, and setup guidance can become founder-led manual work if they are not productized. A customer or MSP should not need a live walkthrough for every tenant connection just to understand what is missing, what is healthy, and what the next action is.
|
||||
- **Why it matters**: TenantPilot cannot scale as a solo-founder or low-headcount SaaS if every pilot, trial, or customer tenant requires manual onboarding support. The product already has ProviderConnection, health, onboarding, operation-run, and permission-related foundations; these need to converge into an operator-facing readiness workflow.
|
||||
- **Proposed direction**:
|
||||
- provide guided tenant setup with clear setup steps and completion state
|
||||
- expose consent readiness and permission diagnostics in product language
|
||||
- show provider connection health and actionable next steps before deeper governance workflows are used
|
||||
- distinguish missing consent, missing permissions, unreachable provider, expired credentials, blocked health checks, and not-yet-run checks
|
||||
- persist or derive an onboarding/readiness status that can be reused by dashboards, support diagnostics, trial flows, and customer review surfaces
|
||||
- keep provider-specific Microsoft details contextual while preserving the provider-boundary language from the platform hardening lane
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: guided onboarding status, readiness checklist, provider connection health summary, permission diagnostics, setup progress, next-action guidance, and tests for readiness semantics
|
||||
- **Out of scope**: full CRM/trial pipeline, billing activation, broad provider marketplace, custom customer-specific onboarding flows, or autonomous tenant remediation
|
||||
- **Acceptance points**:
|
||||
- a new workspace/tenant operator can see which onboarding steps are complete and which are blocking
|
||||
- missing or insufficient Microsoft permissions produce explicit operator guidance rather than generic failure copy
|
||||
- provider connection health is visible without requiring raw run/context inspection
|
||||
- readiness state can be consumed by support diagnostic packs and trial/demo flows
|
||||
- server-side policies still enforce who can view or manage onboarding state
|
||||
- **Risks / open questions**:
|
||||
- Avoid creating a second onboarding model if existing onboarding/session/provider entities can be composed
|
||||
- Readiness must not become a false-green signal; failed or stale health checks need explicit freshness semantics
|
||||
- Provider-specific consent details should not leak into generic platform vocabulary as permanent truth
|
||||
- **Dependencies**: ProviderConnection, managed tenant onboarding workflow, provider health checks, permission/consent diagnostics, OperationRun links, Provider Boundary Hardening
|
||||
- **Related specs / candidates**: Provider Identity & Target Scope Neutrality, Provider Surface Vocabulary & Descriptor Cleanup, Support Diagnostic Pack, Product Knowledge & Contextual Help
|
||||
- **Strategic sequencing**: First item in this product-scalability cluster because it directly reduces manual onboarding and supports trials, demos, support, and customer transparency.
|
||||
- **Priority**: high
|
||||
|
||||
### Support Diagnostic Pack
|
||||
- **Type**: product scalability / supportability foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: Support cases currently risk requiring manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, Report, Evidence, and audit surfaces. Without a reusable diagnostic bundle, every support request becomes an investigation task before the actual issue can be addressed.
|
||||
- **Why it matters**: A low-headcount SaaS needs support context to be captured by the product, not reconstructed by the founder. Diagnostic packs also create the safe input layer for later AI-assisted support summaries and triage without granting an AI or support user broad ad-hoc access to everything.
|
||||
- **Proposed direction**:
|
||||
- define a support diagnostic bundle contract for workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, and review-pack contexts
|
||||
- include relevant health state, latest operation links, failure reason codes, permission/connection state, freshness, artifact references, audit references, and redacted operator summaries
|
||||
- provide an AI-readable but customer-safe summary shape that can be attached to support requests
|
||||
- keep raw sensitive payloads out of the default pack unless explicitly authorized
|
||||
- model redaction and access checks as first-class behavior
|
||||
- allow diagnostic packs to be referenced from in-app support requests and internal support workflows
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: diagnostic pack contract, context collectors, redaction rules, support-safe summary generation, access policy, references to runs/findings/reports/evidence, and tests
|
||||
- **Out of scope**: external ticket-system integration, support desk implementation, AI chat bot, broad log export, customer-visible trust center, or unrestricted raw payload download
|
||||
- **Acceptance points**:
|
||||
- a diagnostic pack can be generated for at least tenant and OperationRun contexts
|
||||
- pack contents are deterministic, scoped, and redacted according to caller capability
|
||||
- the pack links to canonical OperationRun/report/finding/evidence records instead of duplicating truth
|
||||
- sensitive raw provider payloads are excluded by default
|
||||
- tests prove unauthorized users cannot generate packs for unrelated workspaces/tenants
|
||||
- **Risks / open questions**:
|
||||
- Over-including raw context could create data-leak or compliance risk
|
||||
- Under-including context would make the pack less useful and push operators back to manual investigation
|
||||
- The product needs a clear capability boundary, likely related to `platform.support_diagnostics.view` and tenant/workspace support permissions
|
||||
- **Dependencies**: OperationRun link contract, StoredReports / EvidenceItems, Findings workflow, ProviderConnection health, audit log foundation, System Panel least-privilege model
|
||||
- **Related specs / candidates**: In-App Support Request with Context, AI-Assisted Customer Operations, System Panel Least-Privilege Capability Model, OperationRun Start UX Contract
|
||||
- **Strategic sequencing**: Second item after Self-Service Tenant Onboarding; it should land before support volume grows and before AI support triage is introduced.
|
||||
- **Priority**: high
|
||||
|
||||
### In-App Support Request with Context
|
||||
- **Type**: product scalability / support workflow
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: A generic support email or external ticket link loses the most important product context: workspace, tenant, operation, finding, report, evidence, severity, and current diagnostic state. This creates avoidable back-and-forth and makes support impossible to automate cleanly.
|
||||
- **Why it matters**: If TenantPilot is meant to scale with minimal staff, support requests must be structured at the moment they are created. The product should attach the right context automatically instead of relying on customers to describe technical state manually.
|
||||
- **Proposed direction**:
|
||||
- add context-aware support request entry points on selected high-value surfaces
|
||||
- attach workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, or review-pack references automatically
|
||||
- attach or reference a Support Diagnostic Pack when available
|
||||
- capture severity, customer-facing message, optional reproduction notes, and contact metadata
|
||||
- create an internal support reference or external ticket reference when configured
|
||||
- emit an audit event for support request creation where appropriate
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: in-product support request model or outbound adapter seam, context attachment, diagnostic-pack reference, ticket reference field, audit event, capability checks, and first adoption on one or two critical surfaces
|
||||
- **Out of scope**: full helpdesk product, two-way ticket sync, SLA engine, AI support bot, CRM pipeline, or broad customer success automation
|
||||
- **Acceptance points**:
|
||||
- support request created from a run/finding/tenant surface carries the relevant context without manual copy-paste
|
||||
- request creation respects workspace/tenant authorization
|
||||
- diagnostic pack attachment/reference is capability- and redaction-aware
|
||||
- support request status or ticket reference can be shown back in the product where useful
|
||||
- tests prove unrelated tenant context cannot be attached accidentally
|
||||
- **Risks / open questions**:
|
||||
- Decide whether the first version stores support requests in TenantPilot, sends them outbound only, or supports both via an adapter seam
|
||||
- Avoid coupling the product to one helpdesk provider too early
|
||||
- Ensure support request creation does not expose internal-only diagnostic content to customer members
|
||||
- **Dependencies**: Support Diagnostic Pack, audit log foundation, notification/ticket-ref patterns, Customer Review Workspace v1 if customer users can create requests
|
||||
- **Related specs / candidates**: Support Diagnostic Pack, PSA/Ticketing v1, Customer Review Workspace v1, AI-Assisted Customer Operations
|
||||
- **Strategic sequencing**: Third item in this cluster; should follow or minimally depend on the diagnostic-pack contract.
|
||||
- **Priority**: high
|
||||
|
||||
### Product Knowledge & Contextual Help
|
||||
- **Type**: product scalability / operator guidance / support reduction
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: Statuses, findings, drift states, permission requirements, risk acceptance, evidence gaps, and operation outcomes can require founder explanation if the product does not provide contextual help. Existing glossary and reason-code work creates the vocabulary foundation, but not a structured product-help layer.
|
||||
- **Why it matters**: Every unclear state becomes a support ticket, onboarding call, or sales objection. A product knowledge layer also becomes the maintained source for public docs, support macros, AI support summaries, and customer-facing explanations.
|
||||
- **Proposed direction**:
|
||||
- introduce a contextual help registry keyed by feature, surface, status, reason code, and action where appropriate
|
||||
- reuse canonical glossary and reason-code translation semantics instead of inventing local help copy
|
||||
- provide operator-facing explanations for common states such as drift, limited confidence, risk accepted, evidence gap, blocked run, stale run, missing permission, and connection unhealthy
|
||||
- support docs links, troubleshooting hints, and safe next actions
|
||||
- keep machine/audit/export semantics invariant and avoid localizing core identifiers
|
||||
- make the registry usable by later AI-assisted customer operations as a trusted knowledge source
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: help registry, first high-value surface integrations, glossary/reason-code linkage, docs-link structure, troubleshooting snippets, and tests for missing/invalid keys where useful
|
||||
- **Out of scope**: full public documentation site, AI chatbot, complete localization overhaul, legal/compliance claims, or rewriting every help text in the product
|
||||
- **Acceptance points**:
|
||||
- at least two critical surfaces consume contextual help from the registry instead of local hardcoded explanations
|
||||
- help copy references canonical terminology for findings, baseline, drift, risk acceptance, evidence, and operation outcomes
|
||||
- missing help keys fail predictably or degrade gracefully
|
||||
- the registry can expose a machine-readable source set for future AI support without including secrets or customer data
|
||||
- help content is reviewable and versionable as product knowledge, not scattered UI prose
|
||||
- **Risks / open questions**:
|
||||
- Too much help text can make enterprise UI noisy; progressive disclosure is required
|
||||
- Help registry should not become a second source of truth for status semantics
|
||||
- Localization and terminology governance need a clear boundary with Platform Localization v1
|
||||
- **Dependencies**: Platform Vocabulary Glossary, Operator Reason Code Translation, Governance Friction & Operator Vocabulary Hardening, Platform Localization v1 direction
|
||||
- **Related specs / candidates**: AI-Assisted Customer Operations, Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Baseline Compare Scope Guardrails & Ambiguity Guidance
|
||||
- **Strategic sequencing**: Can run in parallel with support diagnostics, but should land before AI-generated customer explanations.
|
||||
- **Priority**: high
|
||||
|
||||
### Plans, Entitlements & Billing Readiness
|
||||
- **Type**: product architecture / commercial scalability foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: TenantPilot needs a product-level way to express plan limits, feature gates, trial/grace status, workspace/tenant/user/report/export/retention limits, and billing state before real customer growth. Without an entitlement foundation, pricing and packaging decisions later require invasive retrofits across RBAC, exports, retention, reports, tenant counts, and customer views.
|
||||
- **Why it matters**: A SaaS cannot scale cleanly if commercial packaging is implemented as scattered conditionals or manual founder decisions. Entitlements are not just billing; they are product behavior, support behavior, trial behavior, and customer expectation management.
|
||||
- **Proposed direction**:
|
||||
- introduce plan and entitlement primitives at workspace/account scope
|
||||
- model feature gates and quantitative limits separately
|
||||
- support trial, active, grace, suspended/read-only, and canceled billing states where appropriate
|
||||
- define enforcement points for tenants, users, exports, retention, reports, evidence packs, and advanced governance features
|
||||
- audit plan changes and entitlement overrides
|
||||
- keep external billing-provider integration behind an adapter seam and out of the initial foundation if needed
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: plan model, entitlement model, feature-gate checks, limit checks, trial/grace/billing status, audit events, first enforcement points, and tests
|
||||
- **Out of scope**: full Stripe integration, payment collection UI, invoice rendering, accounting integration, tax automation, custom enterprise contract engine, or public pricing page
|
||||
- **Acceptance points**:
|
||||
- workspace/account has a resolved plan and entitlement set
|
||||
- feature gates and numeric limits can be checked through a central service instead of scattered conditionals
|
||||
- trial and grace states influence product access in a predictable and tested way
|
||||
- plan changes and overrides are audited
|
||||
- at least one real product limit is enforced through the entitlement service
|
||||
- **Risks / open questions**:
|
||||
- Premature pricing complexity could slow product discovery; start with simple plans and explicit overrides
|
||||
- Enterprise contracts may require manual overrides, but those overrides must remain auditable
|
||||
- Read-only/suspended behavior must be carefully designed so customers do not lose access to evidence or audit history unexpectedly
|
||||
- **Dependencies**: workspace/account model, RBAC/capabilities, audit log foundation, retention/export/report features, Customer Review Workspace direction
|
||||
- **Related specs / candidates**: Customer Review Workspace v1, Review Pack export, Evidence domain, Security Trust Pack Light
|
||||
- **Strategic sequencing**: High priority before broader customer onboarding and paid trials, but can be implemented as a foundation slice without full billing integration.
|
||||
- **Priority**: high
|
||||
|
||||
### Demo & Trial Readiness
|
||||
- **Type**: product scalability / sales enablement foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation and Solo-Founder SaaS Automation track
|
||||
- **Problem**: Demos and trials become manual work if the product cannot provide repeatable demo data, resettable demo workspaces, realistic sample baselines/findings/reports, and a clear trial provisioning path. Without this, sales conversations depend on live manual setup or fragile local data.
|
||||
- **Why it matters**: A solo-founder SaaS needs demos and trials to be repeatable. TenantPilot's value is easier to understand when buyers can see baselines, drift, findings, risk acceptance, evidence packs, and reviews without waiting for a real tenant to produce all states naturally.
|
||||
- **Proposed direction**:
|
||||
- create a demo workspace/sample data mode with seeded tenants, baselines, findings, review packs, evidence, and operation history
|
||||
- provide a reset flow or safe reseed process for demo environments
|
||||
- define demo stories for MSP buyers and enterprise IT buyers
|
||||
- create a trial provisioning checklist that ties into onboarding/readiness and plan/entitlement state
|
||||
- keep demo data clearly marked so it never mixes with production customer truth
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: demo seed data, demo reset support, sample governance artifacts, trial readiness checklist, demo-mode indicators, and tests for data separation
|
||||
- **Out of scope**: CRM pipeline, public signup flow, payment collection, marketing website, fully automated self-serve provisioning, or fake provider execution pretending to be real tenant truth
|
||||
- **Acceptance points**:
|
||||
- demo environment can be prepared repeatably without manual database editing
|
||||
- sample data covers at least baseline, drift/finding, risk acceptance, evidence/report, and operation-run stories
|
||||
- demo/sample data is visibly marked and isolated from real customer data
|
||||
- trial readiness can reuse onboarding/readiness and entitlement foundations
|
||||
- reset/reseed process is safe and documented
|
||||
- **Risks / open questions**:
|
||||
- Fake data must not undermine trust by looking like real Microsoft tenant evidence
|
||||
- Demo mode should not introduce shortcuts into production code paths without explicit safeguards
|
||||
- Trial provisioning may later become its own larger spec once real acquisition flow is known
|
||||
- **Dependencies**: StoredReports / EvidenceItems, Findings workflow, Baseline governance, Self-Service Tenant Onboarding, Plans / Entitlements
|
||||
- **Related specs / candidates**: Customer Review Workspace v1, Tenant Review Run, Product Knowledge & Contextual Help
|
||||
- **Strategic sequencing**: Medium-high. It becomes more urgent once first external demos and pilots become frequent.
|
||||
- **Priority**: medium-high
|
||||
|
||||
### Security Trust Pack Light
|
||||
- **Type**: company-ops / product trust enablement
|
||||
- **Source**: roadmap update 2026-04-25 — Solo-Founder SaaS Automation & Operating Readiness
|
||||
- **Problem**: Enterprise buyers will repeatedly ask how TenantPilot handles hosting, data categories, Microsoft permissions, least privilege, RBAC, retention, backups, audit logs, subprocessors, and what is not stored. If these answers remain ad-hoc, sales and onboarding become founder-dependent and inconsistent.
|
||||
- **Why it matters**: TenantPilot deals with tenant governance artifacts and Microsoft configuration data. Trust documentation is not just legal paperwork; it is a sales and support scalability asset. It also forces the product to stay honest about what it stores, processes, and exposes.
|
||||
- **Proposed direction**:
|
||||
- create a lightweight security trust pack aligned to the actual product architecture and data model
|
||||
- document hosting, data categories, permission model, least-privilege stance, RBAC, audit logging, backup/retention, subprocessors, and non-stored data
|
||||
- map claims to product features and architecture, avoiding unsupported compliance or certification claims
|
||||
- keep the pack versioned and updateable as product capabilities change
|
||||
- identify any product gaps that block truthful trust claims and feed those back into roadmap/spec candidates
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: structured trust-pack content, product-data mapping, permission explanation, security overview, gap list, and maintenance ownership
|
||||
- **Out of scope**: legal finalization, ISO/SOC2 certification, public trust center portal, penetration test execution, or broad security program implementation
|
||||
- **Acceptance points**:
|
||||
- trust pack answers the standard first-pass customer security questions consistently
|
||||
- Microsoft permission explanations match actual provider scopes and product behavior
|
||||
- data categories and retention claims map to real tables/artifacts or documented operating processes
|
||||
- unsupported claims are explicitly avoided
|
||||
- product gaps discovered during trust-pack creation are recorded as roadmap/spec candidates when engineering work is required
|
||||
- **Risks / open questions**:
|
||||
- This is partly non-code work; only engineering gaps should become implementation specs
|
||||
- Legal review may change wording, but not the underlying product truth
|
||||
- Over-claiming compliance posture would damage trust
|
||||
- **Dependencies**: Provider permission model, RBAC model, audit logs, retention behavior, backup behavior, deployment/hosting decisions, AVV/DPA/TOM work
|
||||
- **Related specs / candidates**: Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Provider Boundary Hardening, Evidence domain
|
||||
- **Strategic sequencing**: Should run before serious enterprise sales conversations and before broad customer onboarding.
|
||||
- **Priority**: medium-high
|
||||
|
||||
### AI-Assisted Customer Operations
|
||||
- **Type**: AI-assisted operations / human-in-the-loop product support
|
||||
- **Source**: roadmap update 2026-04-25 — Mid-term AI-Assisted Customer Operations
|
||||
- **Problem**: Customer reviews, support triage, finding explanations, diagnostic summaries, release communication, and report summaries can consume large amounts of founder time. However, unbounded AI automation would be risky in a governance product, especially for tenant-changing actions, customer commitments, legal statements, or risk decisions.
|
||||
- **Why it matters**: TenantPilot can use AI to stay lean, but the product must preserve auditability, human approval, and clear responsibility. The right early AI layer prepares and summarizes work; it does not autonomously change customer tenants or make commitments.
|
||||
- **Proposed direction**:
|
||||
- use structured product truth from diagnostic packs, findings, stored reports, evidence, operation runs, and the product knowledge registry as AI input
|
||||
- generate draft support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and response drafts
|
||||
- require human approval before customer-facing messages, legal statements, risk acceptance, or tenant-changing actions
|
||||
- log AI-generated drafts and human approval where product-relevant
|
||||
- define safety boundaries for what AI can read, suggest, and never execute
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: AI draft/summarization workflows for support, findings, reviews, diagnostics, release notes, and customer explanations; approval gates; audit references; source attribution to product records
|
||||
- **Out of scope**: autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, auto-sending customer communications without review, general-purpose chatbot, or broad AI platform redesign
|
||||
- **Acceptance points**:
|
||||
- generated summaries cite or reference underlying product records rather than inventing unsupported conclusions
|
||||
- customer-facing drafts require human approval before sending or publishing
|
||||
- tenant-changing actions are not executed by AI in this spec
|
||||
- AI access is scoped and redacted through existing permission/diagnostic-pack boundaries
|
||||
- operators can distinguish draft AI text from approved product/customer communication
|
||||
- **Risks / open questions**:
|
||||
- AI hallucination risk must be mitigated through structured inputs and source references
|
||||
- Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider
|
||||
- The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable
|
||||
- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review
|
||||
- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light
|
||||
- **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist.
|
||||
- **Priority**: medium
|
||||
|
||||
> Recommended sequence for this cluster:
|
||||
> 1. **Self-Service Tenant Onboarding & Connection Readiness**
|
||||
> 2. **Support Diagnostic Pack**
|
||||
> 3. **Product Knowledge & Contextual Help**
|
||||
> 4. **In-App Support Request with Context**
|
||||
> 5. **Plans, Entitlements & Billing Readiness**
|
||||
> 6. **Demo & Trial Readiness**
|
||||
> 7. **Security Trust Pack Light**
|
||||
> 8. **AI-Assisted Customer Operations**
|
||||
>
|
||||
|
||||
|
||||
> Additional Solo-Founder Scale Guardrails cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to make the highest-impact solo-founder operating risks measurable, controllable, and product-backed without turning TenantPilot into a CRM, helpdesk, analytics suite, or generic backoffice platform. Pure company-ops artifacts stay in the roadmap; the candidates below are only the product-impacting slices.
|
||||
|
||||
### Product Usage & Adoption Telemetry
|
||||
- **Type**: product observability / adoption analytics foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls
|
||||
- **Problem**: TenantPilot currently risks relying on founder intuition, support tickets, or manual database/log inspection to understand onboarding drop-off, feature adoption, trial health, failed flows, report/export usage, and support-triggering surfaces. Without privacy-aware product telemetry, it is hard to know where customers get stuck or which product areas actually drive value.
|
||||
- **Why it matters**: Low-headcount SaaS requires the product to reveal adoption and friction automatically. Telemetry is also a prerequisite for Customer Health Score, lifecycle communication, trial conversion analysis, and prioritizing product work based on behavior rather than anecdotes.
|
||||
- **Proposed direction**:
|
||||
- define a minimal product telemetry event contract for product usage and adoption signals
|
||||
- capture events such as onboarding step completed/blocked, provider connection checked, baseline capture/compare started, report exported, review pack generated, support request opened, contextual help opened, and trial activation milestones
|
||||
- keep events workspace-/tenant-aware but privacy-aware and avoid raw provider payloads or customer-sensitive data in telemetry
|
||||
- model event name, actor, workspace, tenant, feature area, subject reference, timestamp, and safe metadata
|
||||
- provide aggregate read models for adoption dashboards and customer health scoring
|
||||
- document telemetry boundaries and opt-out / data-processing considerations where appropriate
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: internal product telemetry event model, minimal event capture points, privacy/redaction rules, aggregate usage read model, basic operator visibility, and tests for isolation/redaction
|
||||
- **Out of scope**: full analytics platform, third-party product analytics integration, marketing attribution, session recording, user tracking beyond product-operation needs, or broad BI dashboards
|
||||
- **Acceptance points**:
|
||||
- key onboarding, governance, report/export, and support-intake events can be captured through a central contract
|
||||
- telemetry metadata never stores raw provider payloads or secrets
|
||||
- workspace/tenant isolation is enforced for telemetry reads
|
||||
- aggregate adoption indicators can be queried without scanning arbitrary application logs
|
||||
- telemetry capture can be disabled or bounded by configuration where needed
|
||||
- **Risks / open questions**:
|
||||
- Telemetry must not become invasive or create unnecessary privacy exposure
|
||||
- Too many events too early can create noise; start with high-signal product milestones
|
||||
- Decide whether telemetry is stored in the primary database initially or written through an adapter seam for future external analytics
|
||||
- **Dependencies**: Self-Service Tenant Onboarding & Connection Readiness, OperationRun truth, ProviderConnection health, StoredReports / EvidenceItems, Support Diagnostic Pack, audit/data-processing review
|
||||
- **Related specs / candidates**: Customer Health Score, Customer Lifecycle Communication, Plans / Entitlements & Billing Readiness, Security Trust Pack Light
|
||||
- **Strategic sequencing**: First item in this guardrails cluster because health score and lifecycle communication need reliable usage signals.
|
||||
- **Priority**: high
|
||||
|
||||
### Customer Health Score
|
||||
- **Type**: product observability / customer success signal
|
||||
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls
|
||||
- **Problem**: Churn, inactive customers, unhealthy provider connections, stale baseline compares, unresolved high-risk findings, overdue SLAs, failed runs, expiring risk acceptances, and missing review packs may be noticed too late if the founder has to manually inspect each workspace.
|
||||
- **Why it matters**: A solo-founder or low-headcount SaaS needs a simple, trustworthy signal for which customers or workspaces need attention. This is especially important for MSP-oriented governance, where portfolio risk can grow silently across many tenants.
|
||||
- **Proposed direction**:
|
||||
- derive workspace/customer health indicators from product truth instead of manual notes
|
||||
- combine signals such as onboarding status, last login/activity, provider health, last successful sync, baseline compare freshness, open high findings, overdue findings, expiring risk acceptances, failed/stale OperationRuns, support-request volume, review-pack readiness, and trial/billing status where available
|
||||
- separate health dimensions rather than hiding everything in one opaque score
|
||||
- provide a simple health summary for founder/operator views and later portfolio surfaces
|
||||
- keep customer-health calculations explainable and link back to underlying records
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: health signal registry, derived health dimensions, explainable health summary, workspace/customer-level health read model, first operator view or dashboard card, and tests
|
||||
- **Out of scope**: full customer-success CRM, automated churn prediction, external CRM sync, billing collection, sales pipeline scoring, or AI-generated account management actions
|
||||
- **Acceptance points**:
|
||||
- a workspace/customer health summary can be generated from product data
|
||||
- each health warning links to underlying evidence such as provider health, findings, operations, review packs, or trial state
|
||||
- stale/unknown data is represented explicitly and does not appear healthy by default
|
||||
- customer health is scoped by workspace and respects authorization boundaries
|
||||
- at least one dashboard or operator surface can list unhealthy or attention-needed workspaces
|
||||
- **Risks / open questions**:
|
||||
- A single numeric score can hide important nuance; dimensions should remain visible
|
||||
- Missing data must not be treated as good data
|
||||
- The first version should avoid predictive claims and stay evidence-based
|
||||
- **Dependencies**: Product Usage & Adoption Telemetry, ProviderConnection health, OperationRun truth, Findings workflow, Risk Acceptance/Exceptions, StoredReports / EvidenceItems, Plans / Entitlements & Billing Readiness
|
||||
- **Related specs / candidates**: MSP Portfolio Dashboard, Product Usage & Adoption Telemetry, Customer Lifecycle Communication, Support Diagnostic Pack
|
||||
- **Strategic sequencing**: Second item after telemetry. It should follow reliable signal capture and feed portfolio/customer-success views later.
|
||||
- **Priority**: high
|
||||
|
||||
### Operational Controls & Feature Flags
|
||||
- **Type**: operational safety / platform control plane
|
||||
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls
|
||||
- **Problem**: Incidents or risky product areas may otherwise require code changes, deployments, manual database edits, or ad-hoc communication to pause a feature, block provider-backed actions, disable exports, pause AI functions, stop trials, or place a workspace into a temporary safe state.
|
||||
- **Why it matters**: Solo-founder operations need safe operator controls. TenantPilot contains high-trust workflows such as restore, provider-backed actions, exports, AI-assisted summaries, and evidence/report generation. These need controlled kill switches and scoped feature flags before scale increases incident pressure.
|
||||
- **Proposed direction**:
|
||||
- introduce a minimal operational controls registry with global, workspace, and possibly tenant scope
|
||||
- support kill switches / flags for risky features such as restore execution, provider-backed writes, exports, AI functions, trial provisioning, report generation, and maintenance/read-only modes
|
||||
- expose operator-safe controls in the system/platform plane with strong capabilities and audit logging
|
||||
- define enforcement points through services/gates rather than UI-only hiding
|
||||
- allow time-bound controls with reason and owner where useful
|
||||
- provide clear customer/operator messaging when a feature is disabled or paused
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: feature flag / operational control model, scoped evaluation service, audited changes, first enforcement points, platform/system UI for controls, and tests
|
||||
- **Out of scope**: full experimentation platform, A/B testing, remote-config product, external feature flag vendor integration, broad entitlement replacement, or customer-managed feature flags
|
||||
- **Acceptance points**:
|
||||
- at least one risky feature can be disabled globally and per workspace through a central control
|
||||
- enforcement happens server-side at the action/service boundary
|
||||
- changes are audited with actor, scope, reason, and timestamp
|
||||
- disabled-state messaging is explicit and not confused with authorization failure
|
||||
- tests prove UI hiding is not the only enforcement mechanism
|
||||
- **Risks / open questions**:
|
||||
- Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower
|
||||
- Too many flags can create configuration drift; start with high-risk controls only
|
||||
- Read-only modes need careful definition so evidence/audit access remains available
|
||||
- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness
|
||||
- **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan
|
||||
- **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate.
|
||||
- **Priority**: high
|
||||
|
||||
### Customer Lifecycle Communication
|
||||
- **Type**: customer operations / notification automation
|
||||
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails
|
||||
- **Problem**: Welcome messages, onboarding reminders, trial expiry, provider health warnings, review-pack readiness, risk-expiry reminders, release updates, incidents, renewals, payment issues, and churn-feedback requests can become manual founder communication if they are not structured.
|
||||
- **Why it matters**: Repeatable SaaS delivery depends on consistent customer communication. Some messages are product-triggered and should be model-backed; others belong to company operations. TenantPilot needs a clear product boundary so important lifecycle events can trigger communication without creating a generic marketing automation system.
|
||||
- **Proposed direction**:
|
||||
- define product-triggerable lifecycle communication events for high-value operational moments
|
||||
- start with onboarding incomplete, provider unhealthy, review pack ready, risk acceptance expiring, trial expiring, incident/update notice, and release note availability where product-backed
|
||||
- support templates, recipient resolution, locale, delivery channel abstraction, and audit/reference links where appropriate
|
||||
- distinguish internal operator reminders from customer-facing communication
|
||||
- keep marketing campaigns and CRM nurture sequences outside the first product slice
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: product lifecycle event contract, template registry, recipient resolution, first delivery adapter or outbound hook, audit/reference behavior, and tests for tenant/workspace isolation
|
||||
- **Out of scope**: full marketing automation, newsletter system, CRM pipeline, payment collection, two-way communication inbox, or generic campaign builder
|
||||
- **Acceptance points**:
|
||||
- at least two product-backed lifecycle events can generate structured communication tasks or outbound messages
|
||||
- recipient selection respects workspace/tenant/customer membership and locale where applicable
|
||||
- customer-facing messages reference the relevant product object such as tenant, run, finding, review pack, or risk acceptance
|
||||
- communications are auditable or at least traceable to a product event
|
||||
- customer-facing communication can be disabled or held for manual approval where needed
|
||||
- **Risks / open questions**:
|
||||
- Over-automated customer communication can become noisy or risky during incidents
|
||||
- Billing/payment messages may depend on external billing systems and should not be over-modeled too early
|
||||
- Legal/customer-facing statements may need approval rules before automatic sending
|
||||
- **Dependencies**: Notification Targets / Alerts v1, Product Knowledge & Contextual Help, Plans / Entitlements & Billing Readiness, Customer Health Score, Risk Acceptance/Exceptions, review-pack generation
|
||||
- **Related specs / candidates**: Alerts v1, AI-Assisted Customer Operations, Product Knowledge & Contextual Help, Release & Customer Communication Automation
|
||||
- **Strategic sequencing**: Medium-high. Should follow the first telemetry/health foundations and reuse existing alert/notification infrastructure where possible.
|
||||
- **Priority**: medium-high
|
||||
|
||||
### Product Intake & No-Customization Governance
|
||||
- **Type**: product operations / roadmap governance
|
||||
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails
|
||||
- **Problem**: Customer-specific requests can silently turn TenantPilot into consulting work if they are implemented as one-off behavior, hidden configuration, or customer-specific branches. Without a product intake and no-customization governance path, each sales/support conversation can create long-term maintenance obligations.
|
||||
- **Why it matters**: A low-headcount SaaS must protect the product boundary. Feature requests should become product input, not direct custom work by default. This is especially important for MSP and enterprise customers, where individual requests can sound urgent but may not fit the platform direction.
|
||||
- **Proposed direction**:
|
||||
- define a lightweight feature/request intake model or documented operating process
|
||||
- classify requests as no, later, candidate, planned, customer-specific exception, or already covered
|
||||
- capture customer/segment, problem, workaround, business value, roadmap fit, and maintenance risk
|
||||
- link accepted requests to spec candidates or promoted specs where appropriate
|
||||
- require explicit approval and audit/record for any customer-specific exception
|
||||
- document the no-custom-work policy in product principles or company operating guidance
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: product request classification, link to roadmap/spec candidates, exception semantics, optional internal admin surface, and no-customization policy wording
|
||||
- **Out of scope**: full product management suite, voting portal, public roadmap, customer community, consulting project management, or CRM replacement
|
||||
- **Acceptance points**:
|
||||
- customer requests can be classified consistently without becoming immediate implementation tasks
|
||||
- customer-specific exceptions are explicit, rare, and reviewable
|
||||
- accepted product requests can link to spec candidates or roadmap themes
|
||||
- no-custom-work policy is visible in product/company guidance
|
||||
- the process can be operated manually at first but is structured enough to delegate later
|
||||
- **Risks / open questions**:
|
||||
- This may be mostly process at first; only build product surfaces if manual tracking becomes a bottleneck
|
||||
- Too much process too early could slow learning from pilots
|
||||
- Exceptions need a business owner and expiry/review path so they do not become permanent hidden product variants
|
||||
- **Dependencies**: roadmap/spec-candidate process, principles/constitution, customer support/intake process, Plans / Entitlements if exceptions affect limits or features
|
||||
- **Related specs / candidates**: Plans / Entitlements & Billing Readiness, Customer Lifecycle Communication, Security Trust Pack Light
|
||||
- **Strategic sequencing**: Medium. Add as a principle/process early; promote to product spec only if in-product request/exception tracking becomes necessary.
|
||||
- **Priority**: medium
|
||||
|
||||
### Data Retention, Export & Deletion Self-Service
|
||||
- **Type**: data lifecycle / customer trust / operational scalability
|
||||
- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails
|
||||
- **Problem**: Customer data export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility can become manual support/legal work if the product does not provide clear lifecycle controls and customer-safe visibility.
|
||||
- **Why it matters**: TenantPilot stores governance artifacts, evidence, reports, findings, and operation history. Customers will ask what is retained, what can be exported, what is deleted, and what remains for audit purposes. Self-service or operator-guided lifecycle flows reduce manual work and improve trust.
|
||||
- **Proposed direction**:
|
||||
- define a customer/workspace data lifecycle contract covering active, suspended, archived, trial-expired, deletion-requested, and deleted/retained states where appropriate
|
||||
- expose retention visibility for reports, evidence, operation runs, findings, exceptions, and backups where already modeled
|
||||
- provide customer/operator export request flows and deletion/archive request flows with audit events
|
||||
- make trial data expiry explicit and configurable where tied to plan/entitlement state
|
||||
- distinguish audit-retained records from deleted customer content and communicate that boundary clearly
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: lifecycle state model or request model where needed, export/deletion request flow, retention visibility, audit events, trial expiry handling, and tests for authorization/isolation
|
||||
- **Out of scope**: full GDPR portal, legal policy drafting, automated physical deletion of every historical artifact without retention analysis, external DSR tooling, or broad storage-engine redesign
|
||||
- **Acceptance points**:
|
||||
- customers/operators can see or request export/deletion/archive actions through a defined flow
|
||||
- retention behavior for key artifact families is visible or documented in-product where appropriate
|
||||
- trial-expired data handling is explicit and not ad-hoc
|
||||
- deletion/archive requests are audited and authorized
|
||||
- audit-retained metadata is clearly separated from customer content deletion semantics
|
||||
- **Risks / open questions**:
|
||||
- Legal retention, auditability, and deletion rights must be balanced carefully
|
||||
- Evidence/report retention may intentionally outlive operation runs; this must be visible and not surprising
|
||||
- Automation should start conservative until legal review confirms deletion/retention expectations
|
||||
- **Dependencies**: StoredReports retention, EvidenceItems retention, OperationRun retention, backup retention, Plans / Entitlements & Billing Readiness, Security Trust Pack Light, audit log foundation
|
||||
- **Related specs / candidates**: StoredReports Model, EvidenceItem Model, Export v1, Security Trust Pack Light, Customer Review Workspace v1
|
||||
- **Strategic sequencing**: Medium-high. Should be shaped before broad paid trials and enterprise security reviews, but can land after entitlement and trust-pack foundations.
|
||||
- **Priority**: medium-high
|
||||
|
||||
> Recommended sequence for this cluster:
|
||||
> 1. **Product Usage & Adoption Telemetry**
|
||||
> 2. **Customer Health Score**
|
||||
> 3. **Operational Controls & Feature Flags**
|
||||
> 4. **Data Retention, Export & Deletion Self-Service**
|
||||
> 5. **Customer Lifecycle Communication**
|
||||
> 6. **Product Intake & No-Customization Governance**
|
||||
>
|
||||
> Why this order: first capture reliable signals, then derive health and risk, then add operator control for incidents and risky features, then close customer trust/lifecycle gaps, then automate customer communication, and finally formalize request intake/no-customization once pilot feedback volume increases.
|
||||
|
||||
|
||||
|
||||
|
||||
> Microsoft-first, Provider-extensible Decision-Based Operating cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the decision model should avoid hard-coding Microsoft-only assumptions where provider-neutral abstractions already exist.
|
||||
|
||||
### Decision-Based Governance Inbox v1
|
||||
- **Type**: product strategy / workflow automation / operator UX
|
||||
- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||
- **Problem**: TenantPilot has many rich governance surfaces, but customers and operators can still be forced into search-and-troubleshoot behavior: opening tenants, runs, findings, reports, evidence, provider health, and logs to discover what actually needs a decision. That does not scale for MSPs, customer read-only users, or a low-headcount operating model.
|
||||
- **Why it matters**: TenantPilot should become the decision control plane for accountable Microsoft tenant governance first, not just a browser for tenant state and execution history. The default workflow should be guided decisions; raw detail pages remain available as evidence and diagnostics.
|
||||
- **Proposed direction**:
|
||||
- introduce a Governance Inbox / Action Center that surfaces decision-ready work items across tenants and workspaces
|
||||
- derive inbox items from findings, drift, exceptions, risk acceptances, provider health, failed/stale OperationRuns, review-pack readiness, evidence gaps, and actionable alerts
|
||||
- group, deduplicate, and prioritize related signals so operators do not work the same issue multiple times
|
||||
- show clear decision actions such as review, approve, reject, snooze, assign, accept risk, create ticket, run compare, generate review pack, or request evidence
|
||||
- link every inbox item to underlying evidence and diagnostic surfaces without making drilldown the primary workflow
|
||||
- keep the first implementation Microsoft-first while using provider-neutral descriptors where existing platform abstractions support it
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: decision inbox item model/read model, source adapters for a small set of high-value signals, grouping/dedup rules, severity/priority handling, action affordances, links to evidence/diagnostics, RBAC/workspace scoping, and first operator UI
|
||||
- **Out of scope**: autonomous remediation, broad AI agent, full workflow engine, complete MSP portfolio dashboard replacement, customer-facing remediation actions without approval, or support/CRM replacement
|
||||
- **Acceptance points**:
|
||||
- operators can see a prioritized list of decision-ready governance items without manually visiting each tenant/run/finding/report first
|
||||
- each item includes why it matters, affected tenant/workspace, source records, severity/priority, freshness, and available actions
|
||||
- duplicate/related signals can be grouped or fingerprinted to avoid inbox noise
|
||||
- actions are server-side authorized and routed through existing OperationRun/workflow/audit patterns where applicable
|
||||
- detail pages are reachable as evidence, but the main workflow remains decision-first
|
||||
- tests prove workspace/tenant isolation and prevent unrelated users from seeing inbox items
|
||||
- **Risks / open questions**:
|
||||
- Inbox noise is a major risk; grouping and confidence/freshness semantics matter from v1
|
||||
- The inbox must not become another dashboard that merely links to raw tables
|
||||
- The first slice needs carefully selected sources, likely findings, provider health, stale/failed runs, expiring risk acceptances, and review-pack readiness
|
||||
- Customer-facing visibility may need a later slice with redaction and read-only action limits
|
||||
- **Dependencies**: Findings workflow, Risk Acceptance/Exceptions, OperationRun truth, ProviderConnection health, StoredReports / EvidenceItems, Alerts v1, Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags
|
||||
- **Related specs / candidates**: Decision Pack Contract & Approval Workflow, Findings Operator Inbox v1, Findings Intake & Team Queue v1, Customer Review Workspace v1, MSP Portfolio Dashboard, AI-Assisted Customer Operations
|
||||
- **Strategic sequencing**: High priority after onboarding/support/telemetry/control foundations because it converts those signals into the primary customer/operator workflow.
|
||||
- **Priority**: high
|
||||
|
||||
### Decision Pack Contract & Approval Workflow
|
||||
- **Type**: workflow automation / human-in-the-loop governance contract
|
||||
- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||
- **Problem**: A decision inbox is only useful if each item contains enough context to make a safe decision. Without a structured decision pack, operators still have to manually correlate drift, findings, evidence, operations, provider state, risk acceptance, and recommended action before approving or rejecting work.
|
||||
- **Why it matters**: Human-in-the-loop governance depends on trustworthy, reviewable decision packages: what happened, why it matters, what evidence supports it, what options exist, what the system recommends, what confidence/freshness applies, and what will happen if the operator approves. This is the bridge between detection and controlled execution.
|
||||
- **Proposed direction**:
|
||||
- define a Decision Pack contract with summary, impact, affected tenants/policies, source signals, evidence links, confidence/freshness, recommended action, available actions, and expected execution path
|
||||
- include before/after evidence requirements where an approved action triggers follow-up execution
|
||||
- require human approval for tenant-changing, customer-facing, or risk-accepting actions
|
||||
- route approved follow-up through OperationRuns or controlled workflows rather than direct UI-side execution
|
||||
- audit detection, recommendation, approval/rejection, execution, verification, and evidence attachment
|
||||
- keep Microsoft-specific details contextual while preserving provider-neutral subject/action vocabulary where possible
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: Decision Pack data contract, approval state machine, action registry for first safe actions, audit events, OperationRun handoff, evidence requirements, and tests
|
||||
- **Out of scope**: autonomous remediation, broad policy engine, multi-approver enterprise workflow, advanced AI recommendation engine, external ticketing deep sync, or automatic legal/customer commitments
|
||||
- **Acceptance points**:
|
||||
- a decision pack can be generated for at least one high-value decision source such as critical drift, expiring risk acceptance, failed compare, or review-pack readiness
|
||||
- the pack shows summary, impact, evidence, source records, recommendation, confidence/freshness, and available actions
|
||||
- approval/rejection/snooze/assign actions are audited
|
||||
- tenant-changing or customer-facing actions require explicit approval before execution
|
||||
- approved execution creates or references an OperationRun or controlled workflow record
|
||||
- verification and before/after evidence can be attached or requested where applicable
|
||||
- **Risks / open questions**:
|
||||
- Too much context can overwhelm operators; the pack must be concise with progressive disclosure
|
||||
- Recommendations must not overstate certainty; confidence/freshness must be visible
|
||||
- AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature
|
||||
- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation
|
||||
- **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication
|
||||
- **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready.
|
||||
- **Priority**: high
|
||||
|
||||
### Governance Automation Policy Guardrails v1
|
||||
- **Type**: automation policy / safety guardrails / future autonomous governance foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
|
||||
- **Problem**: As TenantPilot moves from detection to guided action, there will be pressure to automate more of the workflow. Without explicit automation policy guardrails, the product risks drifting into unsafe autopilot behavior or, conversely, never automating safe low-risk follow-up.
|
||||
- **Why it matters**: The product promise is not blind automation. It is accountable governance with human approval where risk matters. Automation policies should define what can be auto-created, auto-assigned, auto-snoozed, auto-notified, or auto-executed, and where approval is mandatory.
|
||||
- **Proposed direction**:
|
||||
- define automation policy guardrails for decision item creation, grouping, assignment, notifications, snoozing, ticket creation, review-pack generation, compare runs, and future remediation execution
|
||||
- classify actions by risk: informational, workflow-only, customer-facing, tenant-changing, risk-accepting, or destructive
|
||||
- require approval for tenant-changing, customer-facing, risk-accepting, or destructive actions
|
||||
- support workspace-level policy defaults and optional stricter tenant-level overrides later
|
||||
- audit policy changes and automation outcomes
|
||||
- integrate with Operational Controls & Feature Flags so automation can be paused safely
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: first automation policy model, action risk taxonomy, approval-required rules, audited policy changes, and enforcement for a small set of workflow-safe actions
|
||||
- **Out of scope**: full rules engine, customer-authored automation scripting, autonomous remediation, complex multi-step playbooks, or cross-provider policy marketplace
|
||||
- **Acceptance points**:
|
||||
- automation policies can distinguish safe workflow automation from approval-required actions
|
||||
- at least one safe action can run automatically and at least one risky action is blocked until approval
|
||||
- policy changes are audited with actor, reason, scope, and timestamp
|
||||
- disabled automation states are clear to operators
|
||||
- tests prove tenant-changing and risk-accepting actions cannot bypass approval through automation
|
||||
- **Risks / open questions**:
|
||||
- Premature policy complexity could slow delivery; start with a small risk taxonomy and a few actions
|
||||
- Workspace vs tenant policy inheritance must be handled carefully to avoid surprising behavior
|
||||
- Automation policy should align with future MSP baseline inheritance and customer override semantics
|
||||
- **Dependencies**: Decision-Based Governance Inbox v1, Decision Pack Contract & Approval Workflow, Operational Controls & Feature Flags, RBAC/capabilities, audit log foundation, Customer Lifecycle Communication
|
||||
- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, MSP Portfolio Dashboard, Rollouts v1, Customer Review Workspace v1, AI-Assisted Customer Operations
|
||||
- **Strategic sequencing**: Medium-high. It should not precede decision inbox and decision pack foundations, but it should land before any autonomous or semi-autonomous remediation features.
|
||||
- **Priority**: medium-high
|
||||
|
||||
> Recommended sequence for this cluster:
|
||||
> 1. **Decision-Based Governance Inbox v1**
|
||||
> 2. **Decision Pack Contract & Approval Workflow**
|
||||
> 3. **Governance Automation Policy Guardrails v1**
|
||||
>
|
||||
> Why this order: first create the decision queue, then make each item decision-ready with evidence and approval semantics, then introduce explicit automation policy guardrails before expanding toward semi-autonomous execution.
|
||||
|
||||
### System Panel Least-Privilege Capability Model
|
||||
- **Type**: security hardening / platform-plane RBAC
|
||||
- **Source**: full codebase quality audit 2026-04-25 — tenant/workspace-plane isolation is strong, but System Panel directory visibility is intentionally global and currently gated by coarse platform capabilities
|
||||
- **Problem**: The System Panel currently exposes global workspace and tenant directory views through broad platform capabilities. This is acceptable for trusted platform superadmins and break-glass operators, but too coarse for enterprise-grade least-privilege support roles, audit expectations, and future support delegation.
|
||||
- **Why it matters**: TenantPilot has strong tenant/workspace isolation elsewhere. If the platform plane remains coarse, the product has an uneven security story: customer-facing tenant access is tight, while internal/operator metadata visibility can still be broader than necessary. Enterprise customers, MSP operators, and auditors will expect support roles to see only the minimum system metadata needed for their task.
|
||||
- **Proposed direction**:
|
||||
- split broad System Panel directory visibility into more granular platform capabilities
|
||||
- distinguish System Panel access, workspace directory visibility, tenant directory visibility, operations visibility, support diagnostics, and break-glass access
|
||||
- keep platform superadmin and emergency break-glass behavior intact
|
||||
- enforce the new boundaries server-side on System Panel pages, not only through navigation hiding
|
||||
- add explicit tests for restricted platform users so unrelated workspace/tenant metadata cannot be enumerated accidentally
|
||||
- **Candidate capabilities**:
|
||||
- `platform.system.access`
|
||||
- `platform.workspaces.view`
|
||||
- `platform.tenants.view`
|
||||
- `platform.operations.view`
|
||||
- `platform.support_diagnostics.view`
|
||||
- `platform.break_glass.use`
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: System Panel page access, platform capability split, server-side authorization checks, navigation visibility alignment, audit-friendly role behavior, and regression tests for non-superadmin platform users
|
||||
- **Out of scope**: redesigning tenant/workspace membership RBAC, changing admin-panel tenant isolation semantics, removing break-glass, adding impersonation, or building a full support-role management UI unless explicitly needed for test fixtures
|
||||
- **Acceptance points**:
|
||||
- existing platform superadmin behavior remains intact
|
||||
- a platform user with only workspace-directory visibility cannot view tenant-directory pages
|
||||
- a platform user with only tenant-directory visibility cannot view workspace-directory pages unless explicitly granted
|
||||
- operations visibility is separately controllable from directory visibility
|
||||
- System Panel pages return forbidden or not-found consistently when capability is missing
|
||||
- tests prove navigation hiding is not the only protection
|
||||
- **Risks / open questions**:
|
||||
- Over-fragmenting capabilities could make platform-user administration noisy before there is a polished role UI
|
||||
- The product needs an explicit decision on whether support diagnostics can reveal tenant metadata without full tenant-directory access
|
||||
- Break-glass behavior must remain simple, auditable, and unmistakably separate from normal support access
|
||||
- **Dependencies**: `PlatformCapabilities`, System Panel providers/pages, platform-user model/policies, existing System Directory tests, existing tenant/workspace isolation tests
|
||||
- **Related specs / candidates**: enterprise auth structure, platform superadmin / break-glass rules, RBAC hardening, System Directory residual surface tests
|
||||
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
|
||||
- **Priority**: high
|
||||
|
||||
### Static Analysis Baseline for Platform Code
|
||||
- **Type**: quality gate / developer experience hardening
|
||||
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
|
||||
- **Problem**: Runtime tests and feature tests are strong, but the codebase lacks a visible static-analysis baseline. In a growing Laravel / Filament / Livewire codebase with large services and resources, relying only on runtime tests leaves type drift, unsafe API usage, dead paths, and refactoring regressions too easy to introduce.
|
||||
- **Why it matters**: TenantPilot is increasingly agent-assisted and spec-driven. Agents can move quickly, but without static analysis they can also reinforce invalid assumptions across dynamic Laravel boundaries. A pragmatic static-analysis gate gives both humans and agents a fast feedback loop before full suites run.
|
||||
- **Proposed direction**:
|
||||
- add Larastan/PHPStan configuration for `apps/platform`
|
||||
- start at a realistic level rather than attempting perfect strictness on day one
|
||||
- generate an explicit baseline if existing findings are too broad for immediate cleanup
|
||||
- make CI fail on new non-baselined findings
|
||||
- document the local and CI workflow for developers and repo agents
|
||||
- track baseline reduction as a future maintenance path rather than bundling all fixes into this spec
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: PHPStan/Larastan setup, baseline generation if needed, CI integration, developer documentation, and a small number of configuration fixes required to make analysis meaningful for Laravel/Filament patterns
|
||||
- **Out of scope**: fixing all existing static-analysis findings, broad refactoring, Rector-driven code rewrites, changing app architecture, or blocking unrelated feature delivery on full strictness immediately
|
||||
- **Acceptance points**:
|
||||
- static analysis runs locally for `apps/platform`
|
||||
- static analysis runs in CI or the active repository pipeline
|
||||
- existing accepted findings are captured in a reviewed baseline
|
||||
- new non-baselined findings fail the quality gate
|
||||
- README, handover, or developer docs explain how to run and update the baseline
|
||||
- configuration accounts for Laravel, Filament, Eloquent factories, and dynamic container usage where appropriate
|
||||
- **Risks / open questions**:
|
||||
- Starting too strict could create a large noisy cleanup spec instead of a useful guardrail
|
||||
- Starting too loose could give false confidence without catching meaningful drift
|
||||
- The repo must decide whether PHPStan/Larastan is enough initially or whether Rector belongs in a later separate modernization lane
|
||||
- **Dependencies**: current Composer tooling, Pest lanes, Gitea workflows, `apps/platform/phpunit.xml`, developer documentation
|
||||
- **Related specs / candidates**: Architecture Boundary Guard Tests, codebase quality hardening, CI/DX hardening
|
||||
- **Strategic sequencing**: Second item in this cluster. It should land before broad hotspot refactors so those refactors have stronger safety rails.
|
||||
- **Priority**: high
|
||||
|
||||
### Architecture Boundary Guard Tests
|
||||
- **Type**: architecture hardening / regression guardrail
|
||||
- **Source**: full codebase quality audit 2026-04-25 — product tests are strong, but architecture-level enforcement is still thin compared with the size and complexity of the codebase
|
||||
- **Problem**: The repo has strong feature, RBAC, browser, and operation-flow tests, but only limited architecture-boundary enforcement. As the platform grows, Filament UI, services, jobs, provider code, models, support registries, and operation-run semantics can drift silently unless dependency and responsibility rules are executable.
|
||||
- **Why it matters**: TenantPilot already has clear architectural intent: UI should not become provider-write logic, jobs should delegate business logic, platform and tenant capabilities should remain separate, and operation-run semantics should stay service-owned. Without guard tests, these principles remain review conventions and can be weakened by future agent-led changes.
|
||||
- **Proposed direction**:
|
||||
- introduce architecture tests that encode the most important dependency and responsibility boundaries
|
||||
- start with high-signal rules rather than broad brittle pattern matching
|
||||
- baseline or explicitly document accepted legacy violations
|
||||
- connect new tests to the active quality-gate lane
|
||||
- use the tests as a safety rail before decomposing large Filament/service hotspots
|
||||
- **Candidate guardrails**:
|
||||
- Filament Resources must not directly perform provider writes
|
||||
- Filament Resources must not own large workflow orchestration
|
||||
- Jobs should delegate business logic to services or handlers
|
||||
- provider-specific code must not leak into neutral platform domains
|
||||
- Models must not depend on Filament
|
||||
- Services must not depend on Filament Resources
|
||||
- Support registries must not depend on UI classes
|
||||
- platform capabilities and tenant/workspace capabilities must remain separated
|
||||
- OperationRun lifecycle and outcome semantics stay service-owned
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: executable architecture tests, pragmatic baselines/exceptions, dependency-direction checks, responsibility-boundary checks, and CI integration
|
||||
- **Out of scope**: perfect clean-architecture purity, mass refactoring to satisfy idealized rules, changing Laravel/Filament conventions where the framework reasonably expects dynamic coupling, or enforcing line-count thresholds as the only quality metric
|
||||
- **Acceptance points**:
|
||||
- architecture tests run locally and in CI
|
||||
- new violations for selected boundaries fail tests
|
||||
- accepted existing violations are explicitly documented with exit paths or reasons
|
||||
- tests protect at least UI/provider, model/UI, service/UI, platform-capability, and OperationRun ownership boundaries
|
||||
- the rules are specific enough to guide future agent work without blocking legitimate Laravel/Filament usage
|
||||
- **Risks / open questions**:
|
||||
- Over-broad static rules may produce noise and encourage blanket exceptions
|
||||
- Some legacy hotspots may need temporary exceptions until decomposition specs land
|
||||
- The tests should complement, not duplicate, PHPStan/Larastan
|
||||
- **Dependencies**: Static Analysis Baseline for Platform Code, current architecture test setup, existing Action Surface guard tests, platform capability registry, provider contracts
|
||||
- **Related specs / candidates**: Static Analysis Baseline for Platform Code, Filament Hotspot Decomposition Foundation, RestoreService Responsibility Split, Provider Boundary Hardening
|
||||
- **Strategic sequencing**: Third item in this cluster. It can begin alongside static analysis but should be in place before large decomposition work accelerates.
|
||||
- **Priority**: high
|
||||
|
||||
### Filament Hotspot Decomposition Foundation
|
||||
- **Type**: maintainability hardening / UI architecture
|
||||
- **Source**: full codebase quality audit 2026-04-25 — several Filament Resources/Pages are large multi-responsibility hotspots despite an otherwise structured architecture
|
||||
- **Problem**: Several Filament surfaces have grown into large classes that combine table/query construction, form or infolist schema, action definitions, presentation rules, state labels, authorization glue, notifications, and workflow orchestration. This does not make the codebase bad, but it increases review cost, bus-factor risk, regression risk, and future feature cost.
|
||||
- **Known hotspots**:
|
||||
- `ManagedTenantOnboardingWizard.php`
|
||||
- `TenantResource.php`
|
||||
- `FindingResource.php`
|
||||
- `RestoreRunResource.php`
|
||||
- **Why it matters**: Filament is the primary operator UI. If every major surface keeps accumulating local query, action, presenter, and workflow code, the admin experience becomes hard to evolve safely. This is especially risky in an agent-led workflow where large files encourage local patching rather than clean extraction.
|
||||
- **Proposed direction**:
|
||||
- define a repeatable decomposition pattern for large Filament Resources and Pages
|
||||
- extract complex query builders into dedicated query/read-model objects where useful
|
||||
- extract action construction into action builder classes or surface-specific action objects
|
||||
- extract badge, label, state, and helper-text rules into presenters
|
||||
- extract complex form/infolist/table section schemas into reusable schema builders
|
||||
- keep routes, resource names, permissions, and user-facing behavior unchanged during the foundation slice
|
||||
- adopt the pattern on one representative Resource first before migrating all hotspots
|
||||
- **First adoption target**: Prefer `FindingResource.php` or `TenantResource.php` as the first representative target because both expose dense operator-facing surfaces and repeated action/presentation/query patterns.
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: decomposition pattern, first representative Resource/Page adoption, tests proving behavior is unchanged, and one or more architecture guardrails that prevent immediate regression
|
||||
- **Out of scope**: broad UI redesign, changing product behavior, permission-semantic changes, schema changes, visual redesign, or mass migration of every large Filament surface in one spec
|
||||
- **Acceptance points**:
|
||||
- selected Resource/Page loses meaningful line count without changing behavior
|
||||
- extracted classes have clear responsibility names and are easier to test or review
|
||||
- existing UI/feature tests pass unchanged or are updated only for intentional structure-aware guardrails
|
||||
- new or updated architecture tests prevent action/query/presenter logic from growing back into the Resource in the same form
|
||||
- the resulting pattern is documented so future specs and agents can reuse it
|
||||
- **Risks / open questions**:
|
||||
- Extracting too aggressively could create more indirection than clarity
|
||||
- Extracting too little would reduce line count without actually improving responsibility boundaries
|
||||
- Choosing the first adoption surface matters; a volatile feature surface may make behavior-preserving decomposition harder
|
||||
- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, existing Filament resource tests, action-surface guard tests
|
||||
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation (Spec 192), Monitoring Surface Action Hierarchy & Workbench Semantics (Spec 193), Governance Friction & Operator Vocabulary Hardening (Spec 194), RestoreService Responsibility Split
|
||||
- **Strategic sequencing**: Fourth item in this cluster. It should follow static analysis and initial architecture guardrails so the extraction work is safer and easier to review.
|
||||
- **Priority**: high
|
||||
|
||||
### RestoreService Responsibility Split
|
||||
- **Type**: maintainability hardening / safety-critical workflow architecture
|
||||
- **Source**: full codebase quality audit 2026-04-25 — restore logic is safety-critical but currently concentrated in a large service hotspot
|
||||
- **Problem**: `RestoreService.php` has grown into a large multi-responsibility class. Restore is one of TenantPilot's highest-risk workflows because it can affect customer tenant state. Concentrating preview, validation, payload mapping, provider writes, operation tracking, result normalization, and failure classification in one service increases regression risk and makes review harder.
|
||||
- **Why it matters**: Restore is not just another CRUD operation. Operators need predictable preview/apply semantics, accurate failure handling, and auditable operation results. A large service can still work, but it becomes increasingly difficult to change safely, especially as provider-backed actions and restore semantics mature.
|
||||
- **Proposed direction**:
|
||||
- keep `RestoreService` as a thin application-facing facade if preserving its public API is useful
|
||||
- extract restore preview calculation into a focused collaborator
|
||||
- extract restore payload mapping into provider-aware mappers
|
||||
- extract restore validation / precondition checks into a dedicated validator or gate
|
||||
- extract provider write execution into explicit execution handlers
|
||||
- extract restore result normalization and failure classification into focused components
|
||||
- preserve existing OperationRun and audit semantics
|
||||
- **Target responsibility slices**:
|
||||
- restore preview calculation
|
||||
- restore payload mapping
|
||||
- restore validation and preconditions
|
||||
- provider write execution
|
||||
- restore operation/run tracking
|
||||
- restore result normalization
|
||||
- restore failure classification
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: internal responsibility split, behavior-preserving tests, collaborator extraction, thin facade preservation where appropriate, and restore-specific architecture guardrails
|
||||
- **Out of scope**: changing restore UI, changing provider behavior, changing restore operation semantics, adding new restore features, broad provider abstraction redesign, or rewriting the restore engine from scratch
|
||||
- **Acceptance points**:
|
||||
- `RestoreService.php` becomes materially smaller
|
||||
- each extracted class has one clear responsibility
|
||||
- existing restore tests pass
|
||||
- new tests cover at least preview, validation/preconditions, provider write execution, and failure/result handling boundaries
|
||||
- OperationRun lifecycle and audit behavior remain unchanged
|
||||
- the public restore workflow remains behavior-compatible unless an explicit spec requirement says otherwise
|
||||
- **Risks / open questions**:
|
||||
- Restore has real execution risk; decomposition must be behavior-preserving and heavily tested
|
||||
- Poor extraction could hide execution order or transactional semantics across too many classes
|
||||
- Provider-boundary cleanup and restore decomposition must be coordinated so neither creates competing abstractions
|
||||
- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, restore tests, OperationRun semantics, Provider Boundary Hardening
|
||||
- **Related specs / candidates**: Restore Lifecycle Semantic Clarity, Provider-Backed Action Preflight and Dispatch Gate Unification (Spec 216), Provider Boundary Hardening (Spec 237), Filament Hotspot Decomposition Foundation
|
||||
- **Strategic sequencing**: Fifth item in this cluster. It should follow or run shortly after the generic quality gates, but it can be promoted earlier if restore changes become frequent.
|
||||
- **Priority**: high
|
||||
|
||||
> Recommended sequence for this cluster:
|
||||
> 1. **System Panel Least-Privilege Capability Model**
|
||||
> 2. **Static Analysis Baseline for Platform Code**
|
||||
> 3. **Architecture Boundary Guard Tests**
|
||||
> 4. **Filament Hotspot Decomposition Foundation**
|
||||
> 5. **RestoreService Responsibility Split**
|
||||
>
|
||||
> Why this order: first close the enterprise security/least-privilege gap, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage risks.
|
||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage.
|
||||
|
||||
|
||||
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
# Specification Quality Checklist: Provider Identity & Target Scope Neutrality
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-24
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- The spec stays bounded to provider connection identity and target-scope semantics on existing shared surfaces.
|
||||
- Broader governed-subject and compare-boundary work remains an explicit follow-up, not hidden scope inside this draft.
|
||||
@ -1,361 +0,0 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Provider Identity & Target Scope Neutrality Logical Contract
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Logical internal contract for the provider connection target-scope neutrality
|
||||
slice. It describes the normalized shared target-scope descriptor, the
|
||||
operator-facing surface summary, and the bounded neutrality guard result.
|
||||
It is not a commitment to expose public HTTP routes.
|
||||
Review stop: shared provider connection surfaces must use neutral target
|
||||
scope truth by default, carry provider-owned Microsoft identity only as
|
||||
contextual metadata, and resolve remaining provider-boundary drift through
|
||||
document-in-feature or follow-up-spec disposition instead of silent shared
|
||||
platform truth.
|
||||
paths:
|
||||
/logical/provider-connections/{connectionId}/target-scope:
|
||||
get:
|
||||
summary: Read the normalized target-scope descriptor for an existing provider connection
|
||||
operationId: getProviderConnectionTargetScope
|
||||
parameters:
|
||||
- name: connectionId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Normalized target-scope descriptor
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
|
||||
/logical/provider-connections/target-scope/normalize:
|
||||
post:
|
||||
summary: Normalize create or edit input into shared target-scope truth plus contextual provider metadata
|
||||
operationId: normalizeProviderConnectionTargetScope
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionTargetScopeInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Normalized descriptor and optional shared-surface preview
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationSuccess'
|
||||
'422':
|
||||
description: Unsupported provider-scope combination or missing provider context
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationFailure'
|
||||
/logical/provider-connections/{connectionId}/surface-summary:
|
||||
get:
|
||||
summary: Read the default-visible operator-facing summary for a shared provider connection surface
|
||||
operationId: getProviderConnectionSurfaceSummary
|
||||
parameters:
|
||||
- name: connectionId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Shared-surface summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionSurfaceSummary'
|
||||
/logical/provider-connections/neutrality/evaluate:
|
||||
post:
|
||||
summary: Evaluate whether a touched shared provider-connection path preserves neutral target-scope truth
|
||||
operationId: evaluateProviderConnectionNeutrality
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionNeutralityEvaluationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Neutrality evaluation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProviderConnectionNeutralityCheckResult'
|
||||
components:
|
||||
schemas:
|
||||
ProviderConnectionSupportedScopeKind:
|
||||
type: string
|
||||
enum:
|
||||
- tenant
|
||||
ProviderIdentityContextVisibility:
|
||||
type: string
|
||||
enum:
|
||||
- contextual_only
|
||||
- audit_only
|
||||
- troubleshooting_only
|
||||
ProviderIdentityContextMetadata:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
detail_key:
|
||||
type: string
|
||||
detail_label:
|
||||
type: string
|
||||
detail_value:
|
||||
type: string
|
||||
visibility:
|
||||
$ref: '#/components/schemas/ProviderIdentityContextVisibility'
|
||||
required:
|
||||
- provider
|
||||
- detail_key
|
||||
- detail_label
|
||||
- detail_value
|
||||
- visibility
|
||||
ProviderConnectionTargetScopeDescriptor:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
scope_kind:
|
||||
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||
scope_identifier:
|
||||
type: string
|
||||
scope_display_name:
|
||||
type: string
|
||||
shared_label:
|
||||
type: string
|
||||
shared_help_text:
|
||||
type: string
|
||||
required:
|
||||
- provider
|
||||
- scope_kind
|
||||
- scope_identifier
|
||||
- scope_display_name
|
||||
- shared_label
|
||||
- shared_help_text
|
||||
ProviderConnectionTargetScopeInput:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
scope_kind:
|
||||
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||
scope_identifier:
|
||||
type: string
|
||||
scope_display_name:
|
||||
type: string
|
||||
provider_specific_identity:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
required:
|
||||
- provider
|
||||
- scope_kind
|
||||
- scope_identifier
|
||||
- scope_display_name
|
||||
ProviderConnectionTargetScopeNormalizationSuccess:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- normalized
|
||||
provider:
|
||||
type: string
|
||||
scope_kind:
|
||||
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||
target_scope:
|
||||
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
|
||||
contextual_identity_details:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProviderIdentityContextMetadata'
|
||||
failure_code:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
message:
|
||||
type: string
|
||||
preview_summary:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ProviderConnectionSurfaceSummary'
|
||||
- type: 'null'
|
||||
required:
|
||||
- status
|
||||
- provider
|
||||
- scope_kind
|
||||
- target_scope
|
||||
- contextual_identity_details
|
||||
- failure_code
|
||||
- message
|
||||
- preview_summary
|
||||
ProviderConnectionTargetScopeNormalizationFailure:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- blocked
|
||||
provider:
|
||||
type: string
|
||||
scope_kind:
|
||||
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
|
||||
failure_code:
|
||||
type: string
|
||||
enum:
|
||||
- unsupported_provider_scope_combination
|
||||
- missing_provider_context
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- provider
|
||||
- scope_kind
|
||||
- failure_code
|
||||
- message
|
||||
ProviderConnectionSurfaceSummary:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
target_scope:
|
||||
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
|
||||
consent_state:
|
||||
type: string
|
||||
verification_state:
|
||||
type: string
|
||||
readiness_summary:
|
||||
type: string
|
||||
contextual_identity_details:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProviderIdentityContextMetadata'
|
||||
required:
|
||||
- provider
|
||||
- target_scope
|
||||
- consent_state
|
||||
- verification_state
|
||||
- readiness_summary
|
||||
- contextual_identity_details
|
||||
ProviderConnectionNeutralityEvaluationRequest:
|
||||
type: object
|
||||
properties:
|
||||
surface_key:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
surface_ownership:
|
||||
type: string
|
||||
enum:
|
||||
- platform_core
|
||||
- provider_owned
|
||||
default_labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required_fields:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
filter_labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
validation_messages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
helper_copy:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
audit_prose:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allowed_exception_classes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
provider_owned_context:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- surface_key
|
||||
- path
|
||||
- surface_ownership
|
||||
- default_labels
|
||||
- required_fields
|
||||
- filter_labels
|
||||
- validation_messages
|
||||
- helper_copy
|
||||
- audit_prose
|
||||
- allowed_exception_classes
|
||||
- provider
|
||||
- provider_owned_context
|
||||
ProviderConnectionNeutralityCheckResult:
|
||||
type: object
|
||||
description: |
|
||||
Guard result for touched shared provider connection surfaces. A blocked
|
||||
or review_required result must name whether the issue is a default
|
||||
label, filter, required field, validation message, helper copy, audit
|
||||
prose, or missing target-scope descriptor, and must route the close-out
|
||||
through document-in-feature or follow-up-spec.
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- allowed
|
||||
- review_required
|
||||
- blocked
|
||||
surface_key:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
surface_ownership:
|
||||
type: string
|
||||
enum:
|
||||
- platform_core
|
||||
- provider_owned
|
||||
allowed_exception_classes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
violation_code:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- provider_specific_default_label
|
||||
- provider_specific_default_filter
|
||||
- provider_specific_required_field
|
||||
- provider_specific_validation_message
|
||||
- provider_specific_default_helper_copy
|
||||
- provider_specific_default_audit_prose
|
||||
- missing_target_scope_descriptor
|
||||
message:
|
||||
type: string
|
||||
suggested_follow_up:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- document-in-feature
|
||||
- follow-up-spec
|
||||
required:
|
||||
- status
|
||||
- surface_key
|
||||
- path
|
||||
- surface_ownership
|
||||
- allowed_exception_classes
|
||||
- violation_code
|
||||
- message
|
||||
- suggested_follow_up
|
||||
@ -1,140 +0,0 @@
|
||||
# Data Model: Provider Identity & Target Scope Neutrality
|
||||
|
||||
## Overview
|
||||
|
||||
This slice introduces one shared target-scope descriptor, one provider-owned contextual identity metadata shape, one operator-facing connection summary, and one narrow neutrality guard result. No new database persistence is introduced.
|
||||
|
||||
## Entity: ProviderConnectionTargetScopeDescriptor
|
||||
|
||||
- **Purpose**: Express the neutral platform meaning of what a provider connection points to without carrying provider-specific identity as part of the descriptor itself.
|
||||
- **Identity**:
|
||||
- `provider_connection_id` for existing records
|
||||
- draft or create-flow input tuple `(provider, scope_kind, scope_identifier, scope_display_name)` before persistence
|
||||
- **Core fields**:
|
||||
- `provider`
|
||||
- `scope_kind`
|
||||
- `scope_identifier`
|
||||
- `scope_display_name`
|
||||
- `shared_label`
|
||||
- `shared_help_text`
|
||||
- **Validation rules**:
|
||||
- The descriptor must remain renderable without provider-specific labels.
|
||||
- In this current-release slice, `scope_kind` is tenant-only even though the neutral field remains generic for future provider-boundary-safe extension.
|
||||
- `scope_kind`, `scope_identifier`, and `scope_display_name` must be sufficient to describe the neutral target-scope meaning on shared surfaces.
|
||||
- `scope_identifier` and `scope_display_name` must be usable on shared surfaces without relying on Microsoft directory vocabulary.
|
||||
- Provider-owned identity details must live beside the descriptor in contextual metadata or summary shapes, not inside the descriptor itself.
|
||||
|
||||
## Entity: ProviderConnectionTargetScopeNormalizationResult
|
||||
|
||||
- **Purpose**: Represent the deterministic result of normalizing create or edit input before persistence, including explicit blocked outcomes for unsupported combinations or missing provider context.
|
||||
- **Fields**:
|
||||
- `status`
|
||||
- `provider`
|
||||
- `scope_kind`
|
||||
- `target_scope` when `status = normalized`
|
||||
- `contextual_identity_details[]` when `status = normalized`
|
||||
- `preview_summary` when a shared-surface preview can be derived without assuming persisted runtime state
|
||||
- `failure_code`
|
||||
- `message`
|
||||
- **Status values**:
|
||||
- `normalized`
|
||||
- `blocked`
|
||||
- **Failure code values**:
|
||||
- `none`
|
||||
- `unsupported_provider_scope_combination`
|
||||
- `missing_provider_context`
|
||||
- **Validation rules**:
|
||||
- `normalized` results must carry `provider`, `scope_kind`, `target_scope`, `contextual_identity_details[]`, `failure_code = none`, and a human-readable `message`; they may include `preview_summary` only when consent and verification state can be derived without assuming persisted runtime state.
|
||||
- `blocked` results must carry `provider`, `scope_kind`, `failure_code`, and `message` and must not pretend readiness or persisted summary truth exists.
|
||||
- The normalization result must preserve the distinction between neutral target-scope truth and provider-owned contextual identity details.
|
||||
|
||||
## Entity: ProviderIdentityContextMetadata
|
||||
|
||||
- **Purpose**: Carry provider-owned identity details that remain necessary for Microsoft consent, verification, troubleshooting, or audit drill-in.
|
||||
- **Fields**:
|
||||
- `provider`
|
||||
- `detail_key`
|
||||
- `detail_label`
|
||||
- `detail_value`
|
||||
- `visibility`
|
||||
- **Visibility values**:
|
||||
- `contextual_only`
|
||||
- `audit_only`
|
||||
- `troubleshooting_only`
|
||||
- **Validation rules**:
|
||||
- Context metadata must never replace the shared target-scope descriptor on generic provider surfaces.
|
||||
- Microsoft-only labels such as `Entra tenant ID` remain allowed only when `provider = microsoft` and visibility is contextual.
|
||||
|
||||
## Entity: ProviderConnectionSurfaceSummary
|
||||
|
||||
- **Purpose**: Define the default-visible operator-facing summary for shared provider connection surfaces.
|
||||
- **Fields**:
|
||||
- `provider`
|
||||
- `target_scope`
|
||||
- `consent_state`
|
||||
- `verification_state`
|
||||
- `readiness_summary`
|
||||
- `contextual_identity_details[]`
|
||||
- **Validation rules**:
|
||||
- `provider`, `target_scope`, `consent_state`, and `verification_state` must all be visible without opening diagnostics.
|
||||
- `contextual_identity_details[]` must remain secondary to the target-scope summary.
|
||||
- Shared surface summaries must not collapse consent and verification into one ambiguous state.
|
||||
|
||||
## Entity: ProviderConnectionNeutralityCheckResult
|
||||
|
||||
- **Purpose**: Deterministic result shape used by guard tests and review checks.
|
||||
- **Fields**:
|
||||
- `status`
|
||||
- `surface_key`
|
||||
- `path`
|
||||
- `surface_ownership`
|
||||
- `allowed_exception_classes[]`
|
||||
- `violation_code`
|
||||
- `message`
|
||||
- `suggested_follow_up`
|
||||
- **Status values**:
|
||||
- `allowed`
|
||||
- `review_required`
|
||||
- `blocked`
|
||||
- **Violation code examples**:
|
||||
- `provider_specific_default_label`
|
||||
- `provider_specific_default_filter`
|
||||
- `provider_specific_required_field`
|
||||
- `provider_specific_validation_message`
|
||||
- `provider_specific_default_helper_copy`
|
||||
- `provider_specific_default_audit_prose`
|
||||
- `missing_target_scope_descriptor`
|
||||
- **Surface ownership values**:
|
||||
- `platform_core`
|
||||
- `provider_owned`
|
||||
- **Validation rules**:
|
||||
- `allowed` means the shared surface uses neutral target-scope truth by default.
|
||||
- `review_required` means the path contains documented provider-owned contextual detail or an allowed exception class.
|
||||
- `blocked` means a shared surface reintroduced provider-specific default truth.
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `ProviderConnectionSurfaceSummary` consumes exactly one `ProviderConnectionTargetScopeDescriptor` and may carry zero or more `ProviderIdentityContextMetadata` entries beside it.
|
||||
- One `ProviderConnectionTargetScopeNormalizationResult` may carry zero or more `ProviderIdentityContextMetadata` entries beside exactly one normalized target-scope descriptor.
|
||||
- One `ProviderConnectionNeutralityCheckResult` references exactly one touched surface or helper path.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### Target-scope descriptor lifecycle
|
||||
|
||||
- `draft_input`: create or edit flow has neutral shared scope data but is not yet persisted.
|
||||
- `persisted_shared_truth`: existing `provider_connections` row has a neutral target-scope descriptor available for shared surfaces.
|
||||
- `context_enriched`: provider-owned contextual details are attached for Microsoft consent, verification, or audit drill-in.
|
||||
|
||||
### Neutrality check lifecycle
|
||||
|
||||
- `allowed`: shared surface is neutral by default.
|
||||
- `review_required`: shared surface stays neutral but exposes documented provider-owned contextual detail.
|
||||
- `blocked`: shared surface or helper reintroduced provider-specific default truth.
|
||||
|
||||
## Rollout Model
|
||||
|
||||
- No new database table or column is planned for this slice.
|
||||
- The descriptor layer is derived from existing provider connection truth and existing provider-owned identity details.
|
||||
- Provider connection list, detail, create, edit, onboarding provider setup, and audit wording adopt the new descriptor first.
|
||||
- Broader provider identity migration and compare-boundary work remain out of scope for this feature.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user