Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
aad837f3d2 merge: agent session work
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m9s
2026-04-24 07:43:24 +02:00
Ahmed Darrazi
f685e327a2 feat: harden baseline truth and onboarding flows 2026-04-24 07:43:24 +02:00
212 changed files with 444 additions and 18940 deletions

View File

@ -1,4 +1,4 @@
[mcp_servers.laravel-boost] [mcp_servers.laravel-boost]
command = "./scripts/platform-sail" command = "vendor/bin/sail"
args = ["artisan", "boost:mcp"] args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/wt-plattform" cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"

View File

@ -248,18 +248,6 @@ ## Active Technologies
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue) - Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
- 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 (235-baseline-capture-truth) - 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 (235-baseline-capture-truth)
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth) - Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- 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 (236-canonical-control-catalog-foundation)
- 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 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness)
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -294,9 +282,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` - 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
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers - 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
- 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 - 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -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.
```

View File

@ -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.

View File

@ -1,30 +1,28 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.9.0 -> 2.10.0 - Version change: 2.8.0 -> 2.9.0
- Modified principles: - Modified principles:
- Expanded Operations / Run Observability Standard so OperationRun - Added provider-boundary guardrail set under First Provider Is Not
start UX is shared-contract-owned instead of surface-owned Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
- Expanded Governance review expectations for OperationRun-starting - Expanded Governance review expectations for provider-owned vs
features, explicit queued-notification policy, and bounded platform-core boundaries
exceptions
- Added sections: - Added sections:
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes - First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
queued toast/link/event/message semantics, run/artifact deep links, the current first provider without allowing provider-specific
queued DB-notification policy, and tenant/workspace-safe operation semantics to silently become platform-core truth; requires explicit
URL resolution behind one shared OperationRun UX layer review of provider-owned vs platform-core seams and prefers bounded
extraction over speculative multi-provider frameworks
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- .specify/templates/spec-template.md: add OperationRun UX Impact - .specify/templates/spec-template.md: add provider-boundary platform
section + start-contract prompts ✅ core check ✅
- .specify/templates/plan-template.md: add OperationRun UX Impact - .specify/templates/plan-template.md: add provider-boundary planning
planning section + constitution checks ✅ fields + constitution check ✅
- .specify/templates/tasks-template.md: add central start-UX reuse, - .specify/templates/tasks-template.md: add provider-boundary task
queued-notification policy, and exception tasks ✅ requirements ✅
- .specify/templates/checklist-template.md: add OperationRun start - .specify/templates/checklist-template.md: add provider-boundary
UX review checks ✅ review checks ✅
- docs/product/standards/README.md: refresh constitution index for
the new ops-UX contract ✅
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present - N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None - Follow-up TODOs: None
@ -309,57 +307,24 @@ ### Operations / Run Observability Standard
even if implemented by multiple jobs/steps (“umbrella run”). even if implemented by multiple jobs/steps (“umbrella run”).
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure. - “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
- Monitoring pages MUST be DB-only at render time (no external calls). - Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only: - Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
authorize, create/reuse run (dedupe), enqueue work, and hand queued/start-state feedback to the shared confirm + “View run”.
OperationRun Start UX Contract.
### OperationRun Start UX Contract (OPS-UX-START-001)
- OperationRun UX MUST be contract-driven, not surface-driven.
- Any feature that creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun` MUST use
the central OperationRun Start UX Contract.
- Filament Pages, Resources, Widgets, Livewire Components, Actions, and Services MUST NOT independently compose
OperationRun start UX from local pieces.
- The shared OperationRun UX layer MUST own:
- local start notification / toast
- `Open operation` / `View run` link
- artifact link such as `View snapshot`, `View pack`, or `View restore`
- run-enqueued browser event
- queued DB-notification decision
- dedupe / already-available / already-running messaging
- blocked / failed-to-start messaging
- tenant/workspace-safe operation URL resolution
- Feature surfaces MAY initiate `OperationRun`s, but they MUST NOT define their own OperationRun UX semantics.
- `OperationRun` lifecycle state remains the canonical execution truth.
- Queued DB notifications MUST remain explicit opt-in unless the active spec defines a different policy.
- Terminal `OperationRun` notifications MUST be emitted through the central OperationRun lifecycle mechanism.
- Any exception MUST include:
1. an explicit spec decision,
2. a documented architecture note,
3. a test or guard-test exception with rationale,
4. a follow-up migration decision if the exception is temporary.
- New OperationRun-starting features MUST include an `OperationRun UX Impact` section in the active spec or plan.
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE) ### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
If a feature creates/reuses `OperationRun`, its default feedback contract is exactly three surfaces. If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
Queued DB notifications are forbidden by default and MAY exist only when the active spec explicitly opts into them
through the OperationRun Start UX Contract:
1) Toast (intent only / queued-only) 1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback). - A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`. - The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts. - Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`. - A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
- Queued toast copy, action links, artifact links, start-state browser events, and dedupe/start-failure messaging MUST be
produced by the shared OperationRun Start UX Contract, not by local surface code.
2) Progress (active awareness only) 2) Progress (active awareness only)
- Live progress MUST exist only in: - Live progress MUST exist only in:
- the global active-ops widget, and - the global active-ops widget, and
- Monitoring → Operation Run Detail. - Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs. - These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Running DB notifications are forbidden.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values. - Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time. - Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed). - The widget MUST NOT show percentage text (optional `processed/total` is allowed).
@ -400,10 +365,6 @@ ### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if: The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`, - any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- feature code bypasses the central OperationRun Start UX Contract for queued/start-state operation UX where the repo's
guardable patterns can detect it,
- feature code emits queued DB notifications for operations without explicit spec-driven opt-in through the shared
OperationRun UX layer,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification), - jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again. - deprecated legacy operation notification classes are referenced again.
@ -1653,11 +1614,6 @@ ### Scope, Compliance, and Review Expectations
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands. - Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge. - Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec. - Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
- Specs and PRs that create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` MUST reuse the
central OperationRun Start UX Contract, keep queued DB notifications explicit opt-in unless the active spec states a
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
and migration decision.
- Specs and PRs that change operator-facing surfaces MUST classify each - Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change. Decision Surface or workflow-first navigation change.
@ -1675,4 +1631,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24 **Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23

View File

@ -40,13 +40,9 @@ mkdir -p "$FEATURE_DIR"
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
if [[ -f "$TEMPLATE" ]]; then if [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN" cp "$TEMPLATE" "$IMPL_PLAN"
if ! $JSON_MODE; then echo "Copied plan template to $IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN"
fi
else else
if ! $JSON_MODE; then echo "Warning: Plan template not found at $TEMPLATE"
echo "Warning: Plan template not found at $TEMPLATE"
fi
# Create a basic plan file if template doesn't exist # Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN" touch "$IMPL_PLAN"
fi fi

View File

@ -32,13 +32,6 @@ ## Shared Pattern Reuse
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control. - [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded. - [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## OperationRun Start UX Contract
- [ ] CHK019 The change explicitly says whether it creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`, and the required `OperationRun UX Impact` section exists when applicable.
- [ ] CHK020 Queued toast/link/artifact-link/browser-event/dedupe-or-blocked messaging and tenant/workspace-safe operation URL resolution are delegated to the shared OperationRun UX contract instead of local surface code.
- [ ] CHK021 Any queued DB notification is explicit opt-in in the active spec or plan, running DB notifications remain absent, and terminal notifications still flow through the central lifecycle mechanism.
- [ ] CHK022 Any exception records the explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision.
## Provider Boundary And Vocabulary ## Provider Boundary And Vocabulary
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary. - [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.

View File

@ -54,18 +54,6 @@ ## Shared Pattern & System Fit
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth] - **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule] - **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: [yes / no / N/A]
- **Central contract reused**: [shared OperationRun UX layer / `N/A`]
- **Delegated UX behaviors**: [queued toast / run link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Surface-owned behavior kept local**: [initiation inputs only / none / short explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception path**: [none / spec decision + architecture note + test rationale + temporary migration follow-up]
## Provider Boundary & Portability Fit ## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.** > **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
@ -91,8 +79,7 @@ ## Constitution Check
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics) - RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked - Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun` - Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- OperationRun start UX: any feature that creates, queues, deduplicates, resumes, blocks, completes, or links `OperationRun` reuses the central OperationRun Start UX Contract; no local composition of queued toast/link/event/start-state messaging; `OperationRun UX Impact` is present in the active spec or plan - Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
- Ops-UX 3-surface feedback: if `OperationRun` is used, default feedback is toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); queued DB notifications remain explicit opt-in through the shared start UX contract; running DB notifications stay disallowed
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside - Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only - Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress - Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress

View File

@ -47,16 +47,6 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links] - **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local patterns] - **Review focus**: [What reviewers must verify to prevent parallel local patterns]
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: [yes/no]
- **Shared OperationRun UX contract/layer reused**: [Name it or `N/A`]
- **Delegated start/completion UX behaviors**: [queued toast / `Open operation` or `View run` link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Local surface-owned behavior that remains**: [initiation inputs only / none / bounded explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception required?**: [none / explicit spec decision + architecture note + test or guard-test rationale + temporary migration follow-up]
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* ## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: [yes/no] - **Shared provider/platform boundary touched?**: [yes/no]
@ -273,21 +263,12 @@ ## Requirements *(mandatory)*
- and the exact minimal validation commands reviewers should run. - and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST: **Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the default Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification) and whether any queued DB notification is explicitly opted into, - explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`), - state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules, - describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring), - clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI. - list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (OPS-UX-START-001):** If this feature creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun`, the spec MUST:
- include the `OperationRun UX Impact` section,
- name the shared OperationRun UX contract/layer being reused,
- delegate queued toast/link/artifact-link/browser-event/queued-DB-notification/dedupe-or-blocked messaging/tenant-safe URL resolution to that shared path,
- keep local surface code limited to initiation inputs and operation-specific data capture,
- keep queued DB notifications explicit opt-in unless the spec intentionally defines a different policy,
- route terminal notifications through the central lifecycle mechanism,
- and document any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary follow-up migration decision.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: **Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`), - state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404), - ensure any cross-plane access is deny-as-not-found (404),

View File

@ -18,22 +18,17 @@ # Tasks: [FEATURE NAME]
- record budget, baseline, or trend follow-up when runtime cost shifts materially, - record budget, baseline, or trend follow-up when runtime cost shifts materially,
- and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`. - and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a **Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub through the shared OperationRun start UX path rather than local surface composition. canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant). If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`. without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include: If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- reusing the central OperationRun Start UX Contract instead of composing local queued toast/link/event/dedupe/blocked/start-failure semantics,
- delegating `Open operation` / `View run`, artifact links, run-enqueued browser event, queued DB-notification policy, dedupe / already-available / already-running messaging, blocked / failed-to-start messaging, and tenant/workspace-safe URL resolution to the shared OperationRun UX layer,
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only), - enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
- keeping queued DB notifications explicit opt-in in the active spec unless a different policy is intentionally approved, and ensuring running DB notifications do not exist, - ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
- routing terminal notifications through the central lifecycle mechanism rather than feature-local notification code,
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`, - ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only, - ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress, - adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system), - clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
- documenting any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision,
- and ensuring the active spec or plan contains an `OperationRun UX Impact` section.
**RBAC**: If this feature introduces or changes authorization, tasks MUST include: **RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions, - explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics: - explicit 404 vs 403 semantics:

View File

@ -4,7 +4,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -51,7 +50,7 @@ public function handle(): int
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict( $opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::DirectoryGroupsSync->value, type: 'entra_group_sync',
identityInputs: [ identityInputs: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'slot_key' => $slotKey, 'slot_key' => $slotKey,

View File

@ -11,7 +11,6 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunType;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -169,12 +168,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
'tenant_id' => (int) $tenant->id, 'tenant_id' => (int) $tenant->id,
'user_id' => null, 'user_id' => null,
'initiator_name' => 'System', 'initiator_name' => 'System',
'type' => OperationRunType::BackupSchedulePurge->value, 'type' => 'backup_schedule_purge',
'status' => 'completed', 'status' => 'completed',
'outcome' => 'succeeded', 'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', implode(':', [ 'run_identity_hash' => hash('sha256', implode(':', [
(string) $tenant->id, (string) $tenant->id,
OperationRunType::BackupSchedulePurge->value, 'backup_schedule_purge',
now()->toISOString(), now()->toISOString(),
Str::uuid()->toString(), Str::uuid()->toString(),
])), ])),

View File

@ -7,9 +7,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\OperationLifecycleReconciler; use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -30,7 +28,7 @@ public function handle(
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query() $query = OperationRun::query()
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value)) ->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']); ->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) { if ($olderThanMinutes > 0) {

View File

@ -21,7 +21,6 @@
use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
@ -490,7 +489,7 @@ private function compareNowAction(): Action
OpsUxBrowserEvents::dispatchRunEnqueued($this); 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 ? [ ->actions($run instanceof OperationRun ? [
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('Open operation')

View File

@ -18,7 +18,6 @@
use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
@ -811,8 +810,8 @@ private function compareAssignedTenants(): void
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0 $toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value) ? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value); : OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast $toast
->body($summary.' Open Operations for progress and next steps.') ->body($summary.' Open Operations for progress and next steps.')

View File

@ -21,7 +21,6 @@
use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -562,6 +561,6 @@ protected function coverageTruth(): ?TenantCoverageTruth
private function inventorySyncHistoryUrl(Tenant $tenant): string private function inventorySyncHistoryUrl(Tenant $tenant): string
{ {
return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value); return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
} }
} }

View File

@ -8,7 +8,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService; use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
@ -17,17 +16,13 @@
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
@ -42,7 +37,6 @@
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -85,11 +79,6 @@ public function getTitle(): string|Htmlable
*/ */
public ?array $navigationContextPayload = null; public ?array $navigationContextPayload = null;
/**
* @var list<string>
*/
public array $supportDiagnosticsAuditKeys = [];
/** /**
* @return array<Action|ActionGroup> * @return array<Action|ActionGroup>
*/ */
@ -139,10 +128,6 @@ protected function getHeaderActions(): array
? OperationRunLinks::tenantlessView($this->run, $navigationContext) ? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: OperationRunLinks::index()); : OperationRunLinks::index());
if (isset($this->run)) {
$actions[] = $this->openSupportDiagnosticsAction();
}
if (! isset($this->run)) { if (! isset($this->run)) {
return $actions; return $actions;
} }
@ -221,116 +206,6 @@ public function monitoringDetailSummary(): array
]; ];
} }
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy')
->iconButton()
->tooltip('Open support diagnostics')
->color('gray')
->record($this->run)
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading('Support diagnostics')
->modalDescription('Redacted operation context from existing records.')
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->mountUsing(function (): void {
$this->auditOperationSupportDiagnosticsOpen();
})
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
'bundle' => $this->operationRunSupportDiagnosticBundle(),
]));
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
->apply();
}
/**
* @return array<string, mixed>
*/
public function operationRunSupportDiagnosticBundle(): array
{
$user = auth()->user();
$tenant = $this->supportDiagnosticsTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
abort(403);
}
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
}
private function auditOperationSupportDiagnosticsOpen(): void
{
$user = auth()->user();
$tenant = $this->supportDiagnosticsTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
bundle: $this->operationRunSupportDiagnosticBundle(),
user: $user,
);
}
private function supportDiagnosticsTenant(): ?Tenant
{
if (! isset($this->run)) {
return null;
}
$tenant = $this->run->tenant;
if ($tenant instanceof Tenant) {
return $tenant;
}
return $this->run->loadMissing('tenant')->tenant;
}
/**
* @param array<string, mixed> $bundle
*/
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
{
if (! isset($this->run)) {
return;
}
$auditKey = 'operation:'.$this->run->getKey();
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
return;
}
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
tenant: $tenant,
contextType: 'operation_run',
bundle: $bundle,
actor: $user,
operationRun: $this->run,
);
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
public function mount(OperationRun $run): void public function mount(OperationRun $run): void
{ {
$user = auth()->user(); $user = auth()->user();
@ -632,14 +507,12 @@ private function canResumeCapture(): bool
return false; return false;
} }
$canonicalType = OperationCatalog::canonicalCode((string) $this->run->type); if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
return false; return false;
} }
$context = is_array($this->run->context) ? $this->run->context : []; $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_capture.resume_token'
: 'baseline_compare.resume_token'; : 'baseline_compare.resume_token';
$token = data_get($context, $tokenKey); $token = data_get($context, $tokenKey);

View File

@ -11,28 +11,13 @@
use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations; use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Dashboard\RecoveryReadiness; use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Dashboard; use Filament\Pages\Dashboard;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration; use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class TenantDashboard extends Dashboard class TenantDashboard extends Dashboard
{ {
/**
* @var list<string>
*/
public array $supportDiagnosticsAuditKeys = [];
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -61,101 +46,4 @@ public function getColumns(): int|array
{ {
return 2; return 2;
} }
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->openSupportDiagnosticsAction(),
];
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy')
->color('gray')
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading('Support diagnostics')
->modalDescription('Redacted tenant context from existing records.')
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen();
})
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
'bundle' => $this->tenantSupportDiagnosticBundle(),
]));
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
->apply();
}
/**
* @return array<string, mixed>
*/
public function tenantSupportDiagnosticBundle(): array
{
$user = auth()->user();
$tenant = Filament::getTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
abort(403);
}
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
}
private function auditTenantSupportDiagnosticsOpen(): void
{
$user = auth()->user();
$tenant = Filament::getTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
bundle: $this->tenantSupportDiagnosticBundle(),
user: $user,
);
}
/**
* @param array<string, mixed> $bundle
*/
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
{
$auditKey = 'tenant:'.$tenant->getKey();
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
return;
}
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
tenant: $tenant,
contextType: 'tenant',
bundle: $bundle,
actor: $user,
);
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
} }

View File

@ -25,7 +25,6 @@
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -458,7 +457,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value, type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -529,7 +528,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value, type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -756,7 +755,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value, type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -853,7 +852,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value, type: 'backup_schedule_run',
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,

View File

@ -32,8 +32,6 @@
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -875,7 +873,7 @@ private static function latestBaselineCaptureEnvelope(BaselineProfile $profile):
{ {
$run = OperationRun::query() $run = OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id) ->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('context->baseline_profile_id', (int) $profile->getKey())
->where('status', 'completed') ->where('status', 'completed')
->orderByDesc('completed_at') ->orderByDesc('completed_at')

View File

@ -17,7 +17,6 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
@ -341,8 +340,8 @@ private function compareAssignedTenantsAction(): Action
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0 $toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value) ? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value); : OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast $toast
->body($summary.' Open Operations for progress and next steps.') ->body($summary.' Open Operations for progress and next steps.')

View File

@ -15,7 +15,6 @@
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -176,7 +175,7 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::InventorySync->value, type: 'inventory_sync',
identityInputs: [ identityInputs: [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
], ],

View File

@ -27,7 +27,6 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
@ -231,9 +230,7 @@ public static function table(Table $table): Table
return $query; return $query;
} }
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical( return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
OperationCatalog::canonicalCode($value),
));
}), }),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())), ->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); $baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record); $baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($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); $baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
if ($baselineCaptureEvidence !== []) { if ($baselineCaptureEvidence !== []) {
@ -1449,7 +1446,7 @@ private static function reconciliationPayload(OperationRun $record): array
*/ */
private static function inventorySyncCoverageSection(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; return null;
} }

View File

@ -20,15 +20,12 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus; 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\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -53,7 +50,6 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use InvalidArgumentException;
use UnitEnum; use UnitEnum;
class ProviderConnectionResource extends Resource class ProviderConnectionResource extends Resource
@ -488,62 +484,6 @@ private static function verificationStatusLabelFromState(mixed $state): string
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; 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 public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -556,17 +496,11 @@ public static function form(Schema $schema): Schema
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255), ->maxLength(255),
TextInput::make('entra_tenant_id') TextInput::make('entra_tenant_id')
->label('Target scope ID') ->label('Entra tenant ID')
->required() ->required()
->maxLength(255) ->maxLength(255)
->helperText(static::targetScopeHelpText())
->validationAttribute('target scope ID')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']), ->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') Placeholder::make('connection_type_display')
->label('Connection type') ->label('Connection type')
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)), ->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
@ -629,9 +563,8 @@ public static function infolist(Schema $schema): Schema
->label('Display name'), ->label('Display name'),
Infolists\Components\TextEntry::make('provider') Infolists\Components\TextEntry::make('provider')
->label('Provider'), ->label('Provider'),
Infolists\Components\TextEntry::make('target_scope') Infolists\Components\TextEntry::make('entra_tenant_id')
->label('Target scope') ->label('Entra tenant ID')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
->copyable(), ->copyable(),
Infolists\Components\TextEntry::make('connection_type') Infolists\Components\TextEntry::make('connection_type')
->label('Connection type') ->label('Connection type')
@ -681,11 +614,6 @@ public static function infolist(Schema $schema): Schema
->label('Migration review') ->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($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') Infolists\Components\TextEntry::make('last_error_reason_code')
->label('Last error reason') ->label('Last error reason')
->placeholder('n/a'), ->placeholder('n/a'),
@ -743,15 +671,9 @@ public static function table(Table $table): Table
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}), }),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(), Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('provider') Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
->label('Provider') Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)), Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
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('connection_type') Tables\Columns\TextColumn::make('connection_type')
->label('Connection type') ->label('Connection type')
->badge() ->badge()
@ -950,7 +872,7 @@ public static function makeInventorySyncAction(): Actions\Action
static::handleProviderOperationAction( static::handleProviderOperationAction(
record: $record, record: $record,
gate: $gate, gate: $gate,
operationType: OperationRunType::InventorySync->value, operationType: 'inventory_sync',
blockedTitle: 'Inventory sync blocked', blockedTitle: 'Inventory sync blocked',
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void { dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
ProviderInventorySyncJob::dispatch( ProviderInventorySyncJob::dispatch(
@ -1027,7 +949,10 @@ public static function makeSetDefaultAction(): Actions\Action
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.default_set', action: 'provider_connection.default_set',
context: [ context: [
'metadata' => static::targetScopeAuditMetadata($record), 'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1089,12 +1014,15 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.connection_type_changed', action: 'provider_connection.connection_type_changed',
context: [ 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, 'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value, 'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'], 'client_id' => (string) $data['client_id'],
'source' => $source, 'source' => $source,
]), ],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1233,11 +1161,14 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.connection_type_changed', action: 'provider_connection.connection_type_changed',
context: [ 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, 'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value, 'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => $source, 'source' => $source,
]), ],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1302,12 +1233,14 @@ public static function makeEnableConnectionAction(): Actions\Action
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.enabled', action: 'provider_connection.enabled',
context: [ context: [
'metadata' => static::targetScopeAuditMetadata($record, [ 'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'enabled', 'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value, 'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials, 'credentials_present' => $hadCredentials,
]), ],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1369,10 +1302,12 @@ public static function makeDisableConnectionAction(): Actions\Action
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.disabled', action: 'provider_connection.disabled',
context: [ context: [
'metadata' => static::targetScopeAuditMetadata($record, [ 'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled', 'to_lifecycle' => 'disabled',
]), ],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,

View File

@ -9,12 +9,9 @@
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
class CreateProviderConnection extends CreateRecord class CreateProviderConnection extends CreateRecord
{ {
@ -31,21 +28,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
} }
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $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 [ return [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -88,9 +70,11 @@ protected function afterCreate(): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.created', action: 'provider_connection.created',
context: [ context: [
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [ 'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'connection_type' => $record->connection_type->value, 'connection_type' => $record->connection_type->value,
]), ],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,

View File

@ -19,8 +19,6 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -28,7 +26,6 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class EditProviderConnection extends EditRecord class EditProviderConnection extends EditRecord
{ {
@ -80,22 +77,6 @@ protected function mutateFormDataBeforeSave(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
unset($data['is_default']); 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; return $data;
} }
@ -138,9 +119,11 @@ protected function afterSave(): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.updated', action: 'provider_connection.updated',
context: [ context: [
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [ 'metadata' => [
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields), 'provider' => $record->provider,
]), 'entra_tenant_id' => $record->entra_tenant_id,
'fields' => $changedFields,
],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -156,7 +139,10 @@ protected function afterSave(): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.default_set', action: 'provider_connection.default_set',
context: [ context: [
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record), 'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,

View File

@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
public function getTableEmptyStateHeading(): ?string public function getTableEmptyStateHeading(): ?string
{ {
return 'No provider connections found'; return 'No Microsoft connections found';
} }
public function getTableEmptyStateDescription(): ?string 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 public function getTableEmptyStateActions(): array

View File

@ -14,9 +14,7 @@
use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
@ -58,7 +56,7 @@ protected function getStats(): array
$inventoryOps = (int) OperationRun::query() $inventoryOps = (int) OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value)) ->where('type', 'inventory_sync')
->active() ->active()
->count(); ->count();

View File

@ -8,10 +8,8 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -38,10 +36,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
'tenant_id' => (int) $schedule->tenant_id, 'tenant_id' => (int) $schedule->tenant_id,
'user_id' => null, 'user_id' => null,
'initiator_name' => 'System', 'initiator_name' => 'System',
'type' => OperationRunType::BackupScheduleRetention->value, 'type' => 'backup_schedule_retention',
'status' => OperationRunStatus::Running->value, 'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->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' => [ 'context' => [
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
], ],
@ -90,7 +88,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
/** @var Collection<int, int> $keepBackupSetIds */ /** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = OperationRun::query() $keepBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id) ->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('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id) ->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id') ->whereNotNull('context->backup_set_id')
@ -105,7 +103,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
/** @var Collection<int, int> $allBackupSetIds */ /** @var Collection<int, int> $allBackupSetIds */
$allBackupSetIds = OperationRun::query() $allBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id) ->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('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id) ->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id') ->whereNotNull('context->backup_set_id')

View File

@ -452,11 +452,6 @@ private function logVerificationResult(
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status, 'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
'credential_source' => $identity->credentialSource, 'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId, '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, 'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(), 'operation_run_id' => (int) $run->getKey(),
'previous_consent_status' => $previousConsentStatus, 'previous_consent_status' => $previousConsentStatus,

View File

@ -2,8 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -36,11 +34,11 @@ public function tenant(): BelongsTo
public function operationRuns(): HasMany public function operationRuns(): HasMany
{ {
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id') return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
->whereIn('type', array_values(array_unique(array_merge( ->whereIn('type', [
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value), 'backup_schedule_run',
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleRetention->value), 'backup_schedule_retention',
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupSchedulePurge->value), 'backup_schedule_purge',
)))) ])
->where('context->backup_schedule_id', (int) $this->getKey()); ->where('context->backup_schedule_id', (int) $this->getKey());
} }
} }

View File

@ -45,17 +45,4 @@ public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
} }
/**
* @return list<array<string, mixed>>
*/
public function canonicalControlReferences(): array
{
$payload = is_array($this->summary_payload) ? $this->summary_payload : [];
$references = $payload['canonical_controls'] ?? [];
return is_array($references)
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: [];
}
} }

View File

@ -98,7 +98,7 @@ public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|i
: (int) $profile; : (int) $profile;
return $query return $query
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)) ->where('type', OperationRunType::BaselineCompare->value)
->where('context->baseline_profile_id', $profileId); ->where('context->baseline_profile_id', $profileId);
} }
@ -112,7 +112,7 @@ public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $poli
foreach ($policy->coveredTypeNames() as $type) { foreach ($policy->coveredTypeNames() as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void { $query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery $typeQuery
->whereIn('type', OperationCatalog::rawValuesForCanonical($type)) ->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void { ->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery $stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void { ->where(function (Builder $queuedQuery) use ($policy, $type): void {
@ -152,18 +152,12 @@ public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $po
return $query return $query
->active() ->active()
->where(function (Builder $query) use ($coveredTypes, $policy): void { ->where(function (Builder $query) use ($coveredTypes, $policy): void {
$coveredRawTypes = collect($coveredTypes) $query->whereNotIn('type', $coveredTypes);
->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type))
->unique()
->values()
->all();
$query->whereNotIn('type', $coveredRawTypes);
foreach ($coveredTypes as $type) { foreach ($coveredTypes as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void { $query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery $typeQuery
->whereIn('type', OperationCatalog::rawValuesForCanonical($type)) ->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void { ->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery $stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void { ->where(function (Builder $queuedQuery) use ($policy, $type): void {
@ -349,7 +343,7 @@ public static function latestCompletedCoverageBearingInventorySyncForTenant(int
return static::query() return static::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value)) ->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at') ->whereNotNull('completed_at')
->latest('completed_at') ->latest('completed_at')
@ -484,11 +478,11 @@ public function baselineGapEnvelope(): array
{ {
$context = is_array($this->context) ? $this->context : []; $context = is_array($this->context) ? $this->context : [];
return match ($this->canonicalOperationType()) { return match ((string) $this->type) {
'baseline.compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps')) 'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
? 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') ? data_get($context, 'baseline_capture.gaps')
: [], : [],
default => [], default => [],

View File

@ -192,17 +192,4 @@ public function publishBlockers(): array
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : []; return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
} }
/**
* @return list<array<string, mixed>>
*/
public function canonicalControlReferences(): array
{
$summary = is_array($this->summary) ? $this->summary : [];
$references = $summary['canonical_controls'] ?? [];
return is_array($references)
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: [];
}
} }

View File

@ -5,7 +5,6 @@
namespace App\Services\Audit; namespace App\Services\Audit;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\OperationRun;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
@ -88,49 +87,4 @@ public function logTenantLifecycleAction(
tenant: $tenant, tenant: $tenant,
); );
} }
/**
* @param array<string, mixed> $bundle
*/
public function logSupportDiagnosticsOpened(
Tenant $tenant,
string $contextType,
array $bundle,
?User $actor = null,
?OperationRun $operationRun = null,
): \App\Models\AuditLog {
$sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0;
$referenceCount = collect($bundle['sections'] ?? [])
->sum(static fn (mixed $section): int => is_array($section) && is_array($section['references'] ?? null)
? count($section['references'])
: 0);
return $this->log(
workspace: $tenant->workspace,
action: AuditActionId::SupportDiagnosticsOpened,
context: [
'context_type' => $contextType,
'redaction_mode' => 'default_redacted',
'section_count' => $sectionCount,
'reference_count' => $referenceCount,
'primary_context_id' => $operationRun instanceof OperationRun
? (string) $operationRun->getKey()
: (string) $tenant->getKey(),
],
actor: $actor,
status: 'success',
resourceType: 'support_diagnostic_bundle',
resourceId: $operationRun instanceof OperationRun
? 'operation_run:'.$operationRun->getKey()
: 'tenant:'.$tenant->getKey(),
targetLabel: $operationRun instanceof OperationRun
? 'Support diagnostics for operation #'.$operationRun->getKey()
: 'Support diagnostics for '.$tenant->name,
summary: $operationRun instanceof OperationRun
? 'Support diagnostics opened for operation #'.$operationRun->getKey()
: 'Support diagnostics opened for '.$tenant->name,
operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
tenant: $tenant,
);
}
} }

View File

@ -19,7 +19,6 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE, Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE, Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
@ -64,7 +63,6 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW, Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE, Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
@ -105,7 +103,6 @@ class RoleCapabilityMap
TenantRole::Operator->value => [ TenantRole::Operator->value => [
Capabilities::TENANT_VIEW, Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,

View File

@ -14,7 +14,6 @@
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class EntraGroupSyncService class EntraGroupSyncService
@ -33,7 +32,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
return $this->providerStarts->start( return $this->providerStarts->start(
tenant: $tenant, tenant: $tenant,
connection: null, connection: null,
operationType: OperationRunType::DirectoryGroupsSync->value, operationType: 'entra_group_sync',
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void { dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) $providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id'] ? (int) $run->context['provider_connection_id']

View File

@ -14,7 +14,6 @@
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class RoleDefinitionsSyncService class RoleDefinitionsSyncService
@ -33,7 +32,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
return $this->providerStarts->start( return $this->providerStarts->start(
tenant: $tenant, tenant: $tenant,
connection: null, connection: null,
operationType: OperationRunType::DirectoryRoleDefinitionsSync->value, operationType: 'directory_role_definitions.sync',
dispatcher: function (OperationRun $run) use ($tenant): void { dispatcher: function (OperationRun $run) use ($tenant): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) $providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id'] ? (int) $run->context['provider_connection_id']

View File

@ -219,9 +219,6 @@ public function buildSnapshotPayload(Tenant $tenant): array
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null) 'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
? $findingsSummary['report_bucket_counts'] ? $findingsSummary['report_bucket_counts']
: [], : [],
'canonical_controls' => is_array($findingsSummary['canonical_controls'] ?? null)
? $findingsSummary['canonical_controls']
: [],
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null) 'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
? $findingsSummary['risk_acceptance'] ? $findingsSummary['risk_acceptance']
: [ : [

View File

@ -10,15 +10,12 @@
use App\Services\Findings\FindingRiskGovernanceResolver; use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
use App\Support\Governance\Controls\CanonicalControlResolver;
final class FindingsSummarySource implements EvidenceSourceProvider final class FindingsSummarySource implements EvidenceSourceProvider
{ {
public function __construct( public function __construct(
private readonly FindingRiskGovernanceResolver $governanceResolver, private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics, private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
private readonly CanonicalControlResolver $canonicalControlResolver,
) {} ) {}
public function key(): string public function key(): string
@ -39,7 +36,6 @@ public function collect(Tenant $tenant): array
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException); $governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException); $governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
$outcome = $this->findingOutcomeSemantics->describe($finding); $outcome = $this->findingOutcomeSemantics->describe($finding);
$canonicalControlResolution = $this->canonicalControlResolutionFor($finding);
return [ return [
'id' => (int) $finding->getKey(), 'id' => (int) $finding->getKey(),
@ -61,7 +57,6 @@ public function collect(Tenant $tenant): array
'report_bucket' => $outcome['report_bucket'], 'report_bucket' => $outcome['report_bucket'],
'governance_state' => $governanceState, 'governance_state' => $governanceState,
] : null, ] : null,
'canonical_control_resolution' => $canonicalControlResolution,
'governance_state' => $governanceState, 'governance_state' => $governanceState,
'governance_warning' => $governanceWarning, 'governance_warning' => $governanceWarning,
]; ];
@ -86,12 +81,6 @@ public function collect(Tenant $tenant): array
$reportBucketCounts[$reportBucket]++; $reportBucketCounts[$reportBucket]++;
} }
} }
$canonicalControls = $entries
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
->unique(static fn (array $control): string => (string) $control['control_key'])
->values()
->all();
$riskAcceptedEntries = $entries->filter( $riskAcceptedEntries = $entries->filter(
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED, static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
@ -126,7 +115,6 @@ public function collect(Tenant $tenant): array
], ],
'outcome_counts' => $outcomeCounts, 'outcome_counts' => $outcomeCounts,
'report_bucket_counts' => $reportBucketCounts, 'report_bucket_counts' => $reportBucketCounts,
'canonical_controls' => $canonicalControls,
'entries' => $entries->all(), 'entries' => $entries->all(),
]; ];
@ -145,68 +133,4 @@ public function collect(Tenant $tenant): array
'sort_order' => 10, 'sort_order' => 10,
]; ];
} }
/**
* @return array<string, mixed>
*/
private function canonicalControlResolutionFor(Finding $finding): array
{
return $this->canonicalControlResolver
->resolve($this->resolutionRequestFor($finding))
->toArray();
}
private function resolutionRequestFor(Finding $finding): CanonicalControlResolutionRequest
{
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$findingType = (string) $finding->finding_type;
if ($findingType === Finding::FINDING_TYPE_PERMISSION_POSTURE) {
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'permission_posture',
workload: 'entra',
signalKey: 'permission_posture.required_graph_permission',
);
}
if ($findingType === Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) {
$roleTemplateId = (string) ($evidence['role_template_id'] ?? '');
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'entra_admin_roles',
workload: 'entra',
signalKey: $roleTemplateId === '62e90394-69f5-4237-9190-012177145e10'
? 'entra_admin_roles.global_admin_assignment'
: 'entra_admin_roles.privileged_role_assignment',
);
}
if ($findingType === Finding::FINDING_TYPE_DRIFT) {
$policyType = is_string($evidence['policy_type'] ?? null) && trim((string) $evidence['policy_type']) !== ''
? trim((string) $evidence['policy_type'])
: 'drift';
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: $policyType,
workload: 'intune',
signalKey: match ($policyType) {
'deviceCompliancePolicy' => 'intune.device_compliance_policy',
'drift' => 'finding.drift',
default => 'intune.device_configuration_drift',
},
);
}
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: $findingType,
);
}
} }

View File

@ -44,7 +44,6 @@ public function collect(Tenant $tenant): array
'entries' => $runs->map(static fn (OperationRun $run): array => [ 'entries' => $runs->map(static fn (OperationRun $run): array => [
'id' => (int) $run->getKey(), 'id' => (int) $run->getKey(),
'type' => (string) $run->type, 'type' => (string) $run->type,
'operation_type' => $run->canonicalOperationType(),
'status' => (string) $run->status, 'status' => (string) $run->status,
'outcome' => (string) $run->outcome, 'outcome' => (string) $run->outcome,
'initiator_name' => $run->user?->name, 'initiator_name' => $run->user?->name,

View File

@ -6,8 +6,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class InventoryMissingService class InventoryMissingService
@ -29,7 +27,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
$latestRun = OperationRun::query() $latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value)) ->where('type', 'inventory_sync')
->where('status', 'completed') ->where('status', 'completed')
->where('context->selection_hash', $selectionHash) ->where('context->selection_hash', $selectionHash)
->orderByDesc('completed_at') ->orderByDesc('completed_at')

View File

@ -59,7 +59,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
'type' => OperationRunType::InventorySync->value, 'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Running->value, 'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->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, [ 'context' => array_merge($normalizedSelection, [
'selection_hash' => $selectionHash, 'selection_hash' => $selectionHash,
]), ]),
@ -698,7 +698,7 @@ private function resolveFoundationPolicyAnchor(
private function selectionLockKey(Tenant $tenant, string $selectionHash): string 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 private function mapGraphFailureToErrorCode(GraphResponse $response): string

View File

@ -12,7 +12,6 @@
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingLifecycleState; use App\Support\Onboarding\OnboardingLifecycleState;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationReportOverall; use App\Support\Verification\VerificationReportOverall;
@ -683,7 +682,7 @@ private function bootstrapOperationTypes(TenantOnboardingSession $draft): array
} }
return array_values(array_filter( 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 !== '', static fn (string $value): bool => $value !== '',
)); ));
} }
@ -710,7 +709,7 @@ private function bootstrapRunMap(TenantOnboardingSession $draft, array $selected
continue; continue;
} }
$runMap[OperationCatalog::canonicalCode($type)] = $normalizedRunId; $runMap[trim($type)] = $normalizedRunId;
} }
} }

View File

@ -1271,7 +1271,7 @@ private function writeTerminalAudit(OperationRun $run): void
action: $action, action: $action,
context: [ context: [
'metadata' => [ 'metadata' => [
'operation_type' => $run->canonicalOperationType(), 'operation_type' => $run->type,
'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []), 'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []),
'failure_summary' => $run->failure_summary, 'failure_summary' => $run->failure_summary,
'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null), 'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null),

View File

@ -13,7 +13,6 @@
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Support\OperationCatalog;
use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\OperationRunCapabilityResolver; use App\Support\Operations\OperationRunCapabilityResolver;
@ -28,9 +27,9 @@ class QueuedExecutionLegitimacyGate
* @var list<string> * @var list<string>
*/ */
private const SYSTEM_AUTHORITY_ALLOWLIST = [ private const SYSTEM_AUTHORITY_ALLOWLIST = [
'backup.schedule.execute', 'backup_schedule_run',
'backup.schedule.retention', 'backup_schedule_retention',
'backup.schedule.purge', 'backup_schedule_purge',
]; ];
public function __construct( public function __construct(
@ -135,7 +134,6 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
public function buildContext(OperationRun $run): QueuedExecutionContext public function buildContext(OperationRun $run): QueuedExecutionContext
{ {
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$operationType = OperationCatalog::canonicalCode((string) $run->type);
$authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null) $authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null)
?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound); ?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound);
$providerConnectionId = $this->resolveProviderConnectionId($context); $providerConnectionId = $this->resolveProviderConnectionId($context);
@ -143,28 +141,26 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
return new QueuedExecutionContext( return new QueuedExecutionContext(
run: $run, run: $run,
operationType: $operationType, operationType: (string) $run->type,
workspaceId: $workspaceId, workspaceId: $workspaceId,
tenant: $run->tenant, tenant: $run->tenant,
initiator: $run->user, initiator: $run->user,
authorityMode: $authorityMode, authorityMode: $authorityMode,
requiredCapability: is_string($context['required_capability'] ?? null) requiredCapability: is_string($context['required_capability'] ?? null)
? $context['required_capability'] ? $context['required_capability']
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType), : $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType((string) $run->type),
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
targetScope: [ targetScope: [
'workspace_id' => $workspaceId, 'workspace_id' => $workspaceId,
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null, 'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
'provider_connection_id' => $providerConnectionId, 'provider_connection_id' => $providerConnectionId,
], ],
prerequisiteClasses: $this->prerequisiteClassesFor($operationType, $providerConnectionId), prerequisiteClasses: $this->prerequisiteClassesFor((string) $run->type, $providerConnectionId),
); );
} }
public function isSystemAuthorityAllowed(string $operationType): bool public function isSystemAuthorityAllowed(string $operationType): bool
{ {
$operationType = OperationCatalog::canonicalCode($operationType);
return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true); return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true);
} }

View File

@ -4,19 +4,11 @@
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
final class PlatformProviderIdentityResolver final class PlatformProviderIdentityResolver
{ {
/** public function resolve(string $tenantContext): ProviderIdentityResolution
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails {
*/
public function resolve(
string $tenantContext,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): ProviderIdentityResolution {
$targetTenant = trim($tenantContext); $targetTenant = trim($tenantContext);
$clientId = trim((string) config('graph.client_id')); $clientId = trim((string) config('graph.client_id'));
$clientSecret = trim((string) config('graph.client_secret')); $clientSecret = trim((string) config('graph.client_secret'));
@ -30,8 +22,6 @@ public function resolve(
credentialSource: 'platform_config', credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: 'Provider connection is missing target tenant scope.', message: 'Provider connection is missing target tenant scope.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }
@ -42,8 +32,6 @@ public function resolve(
credentialSource: 'platform_config', credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityMissing, reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
message: 'Platform app identity is not configured.', message: 'Platform app identity is not configured.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }
@ -54,8 +42,6 @@ public function resolve(
credentialSource: 'platform_config', credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete, reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
message: 'Platform app identity is incomplete.', message: 'Platform app identity is incomplete.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }
@ -67,13 +53,6 @@ public function resolve(
clientSecret: $clientSecret, clientSecret: $clientSecret,
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations', authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
redirectUri: $redirectUri, redirectUri: $redirectUri,
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails !== []
? array_values(array_merge($contextualIdentityDetails, array_filter([
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
])))
: [],
); );
} }
} }

View File

@ -26,7 +26,7 @@ public function enableDedicatedOverride(
$clientSecret = trim($clientSecret); $clientSecret = trim($clientSecret);
if ($clientId === '' || $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 { return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {

View File

@ -4,38 +4,25 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Support\Providers\ProviderReasonCodes; 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 final class ProviderConnectionResolution
{ {
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
*/
private function __construct( private function __construct(
public readonly bool $resolved, public readonly bool $resolved,
public readonly ?ProviderConnection $connection, public readonly ?ProviderConnection $connection,
public readonly ?string $reasonCode, public readonly ?string $reasonCode,
public readonly ?string $extensionReasonCode, public readonly ?string $extensionReasonCode,
public readonly ?string $message, public readonly ?string $message,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly array $contextualIdentityDetails,
) {} ) {}
public static function resolved(ProviderConnection $connection): self public static function resolved(ProviderConnection $connection): self
{ {
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
return new self( return new self(
resolved: true, resolved: true,
connection: $connection, connection: $connection,
reasonCode: null, reasonCode: null,
extensionReasonCode: null, extensionReasonCode: null,
message: null, message: null,
targetScope: $normalizer->descriptorForConnection($connection),
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
); );
} }
@ -45,29 +32,12 @@ public static function blocked(
?string $extensionReasonCode = null, ?string $extensionReasonCode = null,
?ProviderConnection $connection = null, ?ProviderConnection $connection = null,
): self { ): 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( return new self(
resolved: false, resolved: false,
connection: $connection, connection: $connection,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError, reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
extensionReasonCode: $extensionReasonCode, extensionReasonCode: $extensionReasonCode,
message: $message, message: $message,
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }

View File

@ -6,13 +6,11 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
final class ProviderConnectionResolver final class ProviderConnectionResolver
{ {
public function __construct( public function __construct(
private readonly ProviderIdentityResolver $identityResolver, private readonly ProviderIdentityResolver $identityResolver,
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
) {} ) {}
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution 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 ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
$failureCode = $targetScope['failure_code'] ?? ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT;
return ProviderConnectionResolution::blocked( return ProviderConnectionResolution::blocked(
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION ProviderReasonCodes::ProviderConnectionInvalid,
? ProviderReasonCodes::ProviderBindingUnsupported 'Provider connection is missing target tenant scope.',
: ProviderReasonCodes::ProviderConnectionInvalid, 'ext.connection_tenant_missing',
$targetScope['message'] ?? 'Provider connection target scope is invalid.',
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
? 'ext.connection_scope_unsupported'
: 'ext.connection_scope_missing',
$connection, $connection,
); );
} }

View File

@ -6,16 +6,10 @@
use App\Services\Providers\Contracts\HealthResult; use App\Services\Providers\Contracts\HealthResult;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
final class ProviderConnectionStateProjector final class ProviderConnectionStateProjector
{ {
public function surfaceSummary(ProviderConnection $connection): ProviderConnectionSurfaceSummary
{
return ProviderConnectionSurfaceSummary::forConnection($connection);
}
/** /**
* @return array{ * @return array{
* consent_status: ProviderConsentStatus, * consent_status: ProviderConsentStatus,

View File

@ -5,8 +5,6 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use Illuminate\Support\Str;
use RuntimeException;
final class ProviderGateway final class ProviderGateway
{ {
@ -55,17 +53,6 @@ public function request(ProviderConnection $connection, string $method, string $
*/ */
public function graphOptions(ProviderConnection $connection, array $overrides = []): array public function graphOptions(ProviderConnection $connection, array $overrides = []): array
{ {
$resolution = $this->identityResolver->resolve($connection); return $this->identityResolver->resolve($connection)->graphOptions($overrides);
if (! $resolution->resolved || $resolution->effectiveClientId === null || $resolution->clientSecret === null) {
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved.');
}
return array_merge([
'tenant' => $resolution->tenantContext,
'client_id' => $resolution->effectiveClientId,
'client_secret' => $resolution->clientSecret,
'client_request_id' => (string) Str::uuid(),
], $overrides);
} }
} }

View File

@ -4,14 +4,11 @@
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; use Illuminate\Support\Str;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata; use RuntimeException;
final class ProviderIdentityResolution final class ProviderIdentityResolution
{ {
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
*/
private function __construct( private function __construct(
public readonly bool $resolved, public readonly bool $resolved,
public readonly ProviderConnectionType $connectionType, public readonly ProviderConnectionType $connectionType,
@ -23,8 +20,6 @@ private function __construct(
public readonly ?string $redirectUri, public readonly ?string $redirectUri,
public readonly ?string $reasonCode, public readonly ?string $reasonCode,
public readonly ?string $message, public readonly ?string $message,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly array $contextualIdentityDetails,
) {} ) {}
public static function resolved( public static function resolved(
@ -35,8 +30,6 @@ public static function resolved(
?string $clientSecret, ?string $clientSecret,
?string $authorityTenant, ?string $authorityTenant,
?string $redirectUri, ?string $redirectUri,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): self { ): self {
return new self( return new self(
resolved: true, resolved: true,
@ -49,10 +42,6 @@ public static function resolved(
redirectUri: $redirectUri, redirectUri: $redirectUri,
reasonCode: null, reasonCode: null,
message: null, message: null,
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
); );
} }
@ -62,8 +51,6 @@ public static function blocked(
string $credentialSource, string $credentialSource,
string $reasonCode, string $reasonCode,
?string $message = null, ?string $message = null,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): self { ): self {
return new self( return new self(
resolved: false, resolved: false,
@ -76,47 +63,29 @@ public static function blocked(
redirectUri: null, redirectUri: null,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError, reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
message: $message, message: $message,
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext),
); );
} }
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public function graphOptions(array $overrides = []): array
{
if (! $this->resolved || $this->effectiveClientId === null || $this->clientSecret === null) {
throw new RuntimeException($this->message ?? 'Provider identity could not be resolved.');
}
return array_merge([
'tenant' => $this->tenantContext,
'client_id' => $this->effectiveClientId,
'client_secret' => $this->clientSecret,
'client_request_id' => (string) Str::uuid(),
], $overrides);
}
public function effectiveReasonCode(): string public function effectiveReasonCode(): string
{ {
return $this->reasonCode ?? ProviderReasonCodes::UnknownError; 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,
));
}
} }

View File

@ -7,8 +7,6 @@
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialSource; use App\Support\Providers\ProviderCredentialSource;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
@ -17,16 +15,12 @@ final class ProviderIdentityResolver
public function __construct( public function __construct(
private readonly PlatformProviderIdentityResolver $platformResolver, private readonly PlatformProviderIdentityResolver $platformResolver,
private readonly CredentialManager $credentials, private readonly CredentialManager $credentials,
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
) {} ) {}
public function resolve(ProviderConnection $connection): ProviderIdentityResolution public function resolve(ProviderConnection $connection): ProviderIdentityResolution
{ {
$tenantContext = trim((string) $connection->entra_tenant_id); $tenantContext = trim((string) $connection->entra_tenant_id);
$connectionType = $this->resolveConnectionType($connection); $connectionType = $this->resolveConnectionType($connection);
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
$targetScope = $targetScopeResult['target_scope'] ?? null;
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
if ($connectionType === null) { if ($connectionType === null) {
return ProviderIdentityResolution::blocked( return ProviderIdentityResolution::blocked(
@ -35,20 +29,16 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
credentialSource: 'unknown', credentialSource: 'unknown',
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid, reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
message: 'Provider connection type is invalid.', 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( return ProviderIdentityResolution::blocked(
connectionType: $connectionType, connectionType: $connectionType,
tenantContext: 'organizations', tenantContext: 'organizations',
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value, credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.', message: 'Provider connection is missing target tenant scope.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }
@ -59,25 +49,14 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value, credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired, reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
message: 'Provider connection requires migration review before use.', message: 'Provider connection requires migration review before use.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }
if ($connectionType === ProviderConnectionType::Platform) { if ($connectionType === ProviderConnectionType::Platform) {
return $this->platformResolver->resolve( return $this->platformResolver->resolve($tenantContext);
tenantContext: $tenantContext,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
} }
return $this->resolveDedicatedIdentity( return $this->resolveDedicatedIdentity($connection, $tenantContext);
connection: $connection,
tenantContext: $tenantContext,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
} }
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
@ -98,8 +77,6 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
private function resolveDedicatedIdentity( private function resolveDedicatedIdentity(
ProviderConnection $connection, ProviderConnection $connection,
string $tenantContext, string $tenantContext,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): ProviderIdentityResolution { ): ProviderIdentityResolution {
try { try {
$credentials = $this->credentials->getClientCredentials($connection); $credentials = $this->credentials->getClientCredentials($connection);
@ -112,8 +89,6 @@ private function resolveDedicatedIdentity(
? ProviderReasonCodes::DedicatedCredentialInvalid ? ProviderReasonCodes::DedicatedCredentialInvalid
: ProviderReasonCodes::DedicatedCredentialMissing, : ProviderReasonCodes::DedicatedCredentialMissing,
message: $exception->getMessage(), message: $exception->getMessage(),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }
@ -125,8 +100,6 @@ private function resolveDedicatedIdentity(
clientSecret: $credentials['client_secret'], clientSecret: $credentials['client_secret'],
authorityTenant: $tenantContext, authorityTenant: $tenantContext,
redirectUri: trim((string) route('admin.consent.callback')), redirectUri: trim((string) route('admin.consent.callback')),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
); );
} }

View File

@ -7,48 +7,44 @@
final class ProviderOperationRegistry final class ProviderOperationRegistry
{ {
public const string BINDING_ACTIVE = 'active';
public const string BINDING_UNSUPPORTED = 'unsupported';
/** /**
* @return array<string, array{operation_type: string, module: string, label: string, required_capability: string}> * @return array<string, array{provider: string, module: string, label: string, required_capability: string}>
*/ */
public function definitions(): array public function all(): array
{ {
return [ return [
'provider.connection.check' => [ 'provider.connection.check' => [
'operation_type' => 'provider.connection.check', 'provider' => 'microsoft',
'module' => 'health_check', 'module' => 'health_check',
'label' => 'Provider connection check', 'label' => 'Provider connection check',
'required_capability' => Capabilities::PROVIDER_RUN, 'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'inventory.sync' => [ 'inventory_sync' => [
'operation_type' => 'inventory.sync', 'provider' => 'microsoft',
'module' => 'inventory', 'module' => 'inventory',
'label' => 'Inventory sync', 'label' => 'Inventory sync',
'required_capability' => Capabilities::PROVIDER_RUN, 'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'compliance.snapshot' => [ 'compliance.snapshot' => [
'operation_type' => 'compliance.snapshot', 'provider' => 'microsoft',
'module' => 'compliance', 'module' => 'compliance',
'label' => 'Compliance snapshot', 'label' => 'Compliance snapshot',
'required_capability' => Capabilities::PROVIDER_RUN, 'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'restore.execute' => [ 'restore.execute' => [
'operation_type' => 'restore.execute', 'provider' => 'microsoft',
'module' => 'restore', 'module' => 'restore',
'label' => 'Restore execution', 'label' => 'Restore execution',
'required_capability' => Capabilities::TENANT_MANAGE, 'required_capability' => Capabilities::TENANT_MANAGE,
], ],
'directory.groups.sync' => [ 'entra_group_sync' => [
'operation_type' => 'directory.groups.sync', 'provider' => 'microsoft',
'module' => 'directory_groups', 'module' => 'directory_groups',
'label' => 'Directory groups sync', 'label' => 'Directory groups sync',
'required_capability' => Capabilities::TENANT_SYNC, 'required_capability' => Capabilities::TENANT_SYNC,
], ],
'directory.role_definitions.sync' => [ 'directory_role_definitions.sync' => [
'operation_type' => 'directory.role_definitions.sync', 'provider' => 'microsoft',
'module' => 'directory_role_definitions', 'module' => 'directory_role_definitions',
'label' => 'Role definitions sync', 'label' => 'Role definitions sync',
'required_capability' => Capabilities::TENANT_MANAGE, 'required_capability' => Capabilities::TENANT_MANAGE,
@ -56,78 +52,19 @@ public function definitions(): array
]; ];
} }
/**
* @return array<string, array{operation_type: string, module: string, label: string, required_capability: string}>
*/
public function all(): array
{
return $this->definitions();
}
/**
* @return array<string, array<string, array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}>>
*/
public function providerBindings(): array
{
return [
'provider.connection.check' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'provider.connection.check',
handlerNotes: 'Uses the current Microsoft Graph provider connection health-check workflow.',
exceptionNotes: 'Current-release provider binding remains Microsoft-only until a real second provider case exists.',
),
],
'inventory.sync' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'inventory.sync',
handlerNotes: 'Uses the current Microsoft Intune inventory sync workflow.',
exceptionNotes: 'Inventory collection is currently Microsoft Intune-specific provider behavior.',
),
],
'compliance.snapshot' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'compliance.snapshot',
handlerNotes: 'Uses the current Microsoft compliance snapshot workflow.',
exceptionNotes: 'Compliance snapshot runtime remains bounded to the Microsoft provider.',
),
],
'restore.execute' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'restore.execute',
handlerNotes: 'Uses the current Microsoft restore execution workflow.',
exceptionNotes: 'Restore execution remains Microsoft-only and must preserve dry-run and audit safeguards.',
),
],
'directory.groups.sync' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'directory.groups.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' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'directory.role_definitions.sync',
handlerNotes: 'Uses the current Microsoft directory role definition synchronization workflow.',
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
),
],
];
}
public function isAllowed(string $operationType): bool public function isAllowed(string $operationType): bool
{ {
return array_key_exists(trim($operationType), $this->definitions()); return array_key_exists($operationType, $this->all());
} }
/** /**
* @return array{operation_type: string, module: string, label: string, required_capability: string} * @return array{provider: string, module: string, label: string, required_capability: string}
*/ */
public function get(string $operationType): array public function get(string $operationType): array
{ {
$operationType = trim($operationType); $operationType = trim($operationType);
$definition = $this->definitions()[$operationType] ?? null; $definition = $this->all()[$operationType] ?? null;
if (! is_array($definition)) { if (! is_array($definition)) {
throw new InvalidArgumentException("Unknown provider operation type: {$operationType}"); throw new InvalidArgumentException("Unknown provider operation type: {$operationType}");
@ -135,85 +72,4 @@ public function get(string $operationType): array
return $definition; return $definition;
} }
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}|null
*/
public function bindingFor(string $operationType, string $provider): ?array
{
$operationType = trim($operationType);
$provider = trim($provider);
if ($operationType === '' || $provider === '') {
return null;
}
$bindings = $this->providerBindings()[$operationType] ?? [];
return $bindings[$provider] ?? null;
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}|null
*/
public function activeBindingFor(string $operationType): ?array
{
$operationType = trim($operationType);
$bindings = $this->providerBindings()[$operationType] ?? [];
foreach ($bindings as $binding) {
if (($binding['binding_status'] ?? null) === self::BINDING_ACTIVE) {
return $binding;
}
}
return null;
}
/**
* @return array{
* definition: array{operation_type: string, module: string, label: string, required_capability: string},
* binding: array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
* }
*/
public function boundaryOperation(string $operationType, ?string $provider = null): array
{
$definition = $this->get($operationType);
$binding = is_string($provider) && trim($provider) !== ''
? $this->bindingFor($operationType, $provider)
: $this->activeBindingFor($operationType);
return [
'definition' => $definition,
'binding' => $binding ?? $this->unsupportedBinding($operationType, $provider ?? 'unknown'),
];
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
public function unsupportedBinding(string $operationType, string $provider): array
{
return [
'operation_type' => trim($operationType),
'provider' => trim($provider) !== '' ? trim($provider) : 'unknown',
'binding_status' => self::BINDING_UNSUPPORTED,
'handler_notes' => 'No explicit provider binding exists for this operation/provider combination.',
'exception_notes' => 'Unsupported combinations must block explicitly instead of inheriting Microsoft behavior.',
];
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
private function activeMicrosoftBinding(string $operationType, string $handlerNotes, string $exceptionNotes): array
{
return [
'operation_type' => $operationType,
'provider' => 'microsoft',
'binding_status' => self::BINDING_ACTIVE,
'handler_notes' => $handlerNotes,
'exception_notes' => $exceptionNotes,
];
}
} }

View File

@ -42,47 +42,26 @@ public function start(
array $extraContext = [], array $extraContext = [],
): ProviderOperationStartResult { ): ProviderOperationStartResult {
$definition = $this->registry->get($operationType); $definition = $this->registry->get($operationType);
$binding = $this->resolveProviderBinding($operationType, $connection);
if (($binding['binding_status'] ?? null) !== ProviderOperationRegistry::BINDING_ACTIVE) {
return $this->startBlocked(
tenant: $tenant,
operationType: $operationType,
provider: (string) ($binding['provider'] ?? 'unknown'),
module: (string) $definition['module'],
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
extensionReasonCode: 'ext.provider_binding_missing',
reasonMessage: 'No explicit provider binding supports this operation/provider combination.',
connection: $connection,
initiator: $initiator,
extraContext: array_merge($extraContext, [
'provider_binding' => $this->bindingContext($binding),
]),
);
}
$resolution = $connection instanceof ProviderConnection $resolution = $connection instanceof ProviderConnection
? $this->resolver->validateConnection($tenant, (string) $binding['provider'], $connection) ? $this->resolver->validateConnection($tenant, (string) $definition['provider'], $connection)
: $this->resolver->resolveDefault($tenant, (string) $binding['provider']); : $this->resolver->resolveDefault($tenant, (string) $definition['provider']);
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) { if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
return $this->startBlocked( return $this->startBlocked(
tenant: $tenant, tenant: $tenant,
operationType: $operationType, operationType: $operationType,
provider: (string) $binding['provider'], provider: (string) $definition['provider'],
module: (string) $definition['module'], module: (string) $definition['module'],
reasonCode: $resolution->effectiveReasonCode(), reasonCode: $resolution->effectiveReasonCode(),
extensionReasonCode: $resolution->extensionReasonCode, extensionReasonCode: $resolution->extensionReasonCode,
reasonMessage: $resolution->message, reasonMessage: $resolution->message,
connection: $resolution->connection ?? $connection, connection: $resolution->connection ?? $connection,
initiator: $initiator, initiator: $initiator,
extraContext: array_merge($extraContext, [ extraContext: $extraContext,
'provider_binding' => $this->bindingContext($binding),
]),
); );
} }
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult { return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $resolution): ProviderOperationStartResult {
$connection = $resolution->connection; $connection = $resolution->connection;
if (! $connection instanceof ProviderConnection) { if (! $connection instanceof ProviderConnection) {
@ -135,7 +114,6 @@ public function start(
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext), 'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
'provider' => $lockedConnection->provider, 'provider' => $lockedConnection->provider,
'module' => $definition['module'], 'module' => $definition['module'],
'provider_binding' => $this->bindingContext($binding),
'provider_connection_id' => (int) $lockedConnection->getKey(), 'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [ 'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id, 'entra_tenant_id' => $lockedConnection->entra_tenant_id,
@ -257,36 +235,6 @@ private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
$dispatcher(); $dispatcher();
} }
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
private function resolveProviderBinding(string $operationType, ?ProviderConnection $connection): array
{
if ($connection instanceof ProviderConnection) {
$provider = trim((string) $connection->provider);
return $this->registry->bindingFor($operationType, $provider)
?? $this->registry->unsupportedBinding($operationType, $provider);
}
return $this->registry->activeBindingFor($operationType)
?? $this->registry->unsupportedBinding($operationType, 'unknown');
}
/**
* @param array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string} $binding
* @return array{provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
private function bindingContext(array $binding): array
{
return [
'provider' => (string) $binding['provider'],
'binding_status' => (string) $binding['binding_status'],
'handler_notes' => (string) $binding['handler_notes'],
'exception_notes' => (string) $binding['exception_notes'],
];
}
/** /**
* @param array<string, mixed> $extraContext * @param array<string, mixed> $extraContext
*/ */

View File

@ -597,7 +597,7 @@ private function dispatchFailureAlertSafely(OperationRun $run): void
'body' => 'A findings lifecycle backfill run failed.', 'body' => 'A findings lifecycle backfill run failed.',
'metadata' => [ 'metadata' => [
'operation_run_id' => (int) $run->getKey(), 'operation_run_id' => (int) $run->getKey(),
'operation_type' => $run->canonicalOperationType(), 'operation_type' => (string) $run->type,
'scope' => (string) data_get($run->context, 'runbook.scope', ''), 'scope' => (string) data_get($run->context, 'runbook.scope', ''),
'view_run_url' => SystemOperationRunLinks::view($run), 'view_run_url' => SystemOperationRunLinks::view($run),
], ],

View File

@ -14,9 +14,10 @@
final class OperationRunTriageService final class OperationRunTriageService
{ {
private const RETRYABLE_TYPES = [ private const RETRYABLE_TYPES = [
'inventory.sync', 'inventory_sync',
'policy.sync', 'policy.sync',
'directory.groups.sync', 'policy.sync_one',
'entra_group_sync',
'findings.lifecycle.backfill', 'findings.lifecycle.backfill',
'rbac.health_check', 'rbac.health_check',
'entra.admin_roles.scan', 'entra.admin_roles.scan',
@ -25,9 +26,10 @@ final class OperationRunTriageService
]; ];
private const CANCELABLE_TYPES = [ private const CANCELABLE_TYPES = [
'inventory.sync', 'inventory_sync',
'policy.sync', 'policy.sync',
'directory.groups.sync', 'policy.sync_one',
'entra_group_sync',
'findings.lifecycle.backfill', 'findings.lifecycle.backfill',
'rbac.health_check', 'rbac.health_check',
'entra.admin_roles.scan', 'entra.admin_roles.scan',
@ -44,7 +46,7 @@ public function canRetry(OperationRun $run): bool
{ {
return (string) $run->status === OperationRunStatus::Completed->value return (string) $run->status === OperationRunStatus::Completed->value
&& (string) $run->outcome === OperationRunOutcome::Failed->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 public function canCancel(OperationRun $run): bool
@ -53,7 +55,7 @@ public function canCancel(OperationRun $run): bool
OperationRunStatus::Queued->value, OperationRunStatus::Queued->value,
OperationRunStatus::Running->value, OperationRunStatus::Running->value,
], true) ], 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 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, 'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
'user_id' => null, 'user_id' => null,
'initiator_name' => $actor->name ?? 'Platform operator', 'initiator_name' => $actor->name ?? 'Platform operator',
'type' => $run->canonicalOperationType(), 'type' => (string) $run->type,
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))), '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: [ metadata: [
'source_run_id' => (int) $run->getKey(), 'source_run_id' => (int) $run->getKey(),
'new_run_id' => (int) $retryRun->getKey(), 'new_run_id' => (int) $retryRun->getKey(),
'operation_type' => $run->canonicalOperationType(), 'operation_type' => (string) $run->type,
], ],
run: $retryRun, run: $retryRun,
); );
@ -150,7 +152,7 @@ public function cancel(OperationRun $run, PlatformUser $actor, string $reason):
actor: $actor, actor: $actor,
action: 'platform.system_console.cancel', action: 'platform.system_console.cancel',
metadata: [ metadata: [
'operation_type' => $run->canonicalOperationType(), 'operation_type' => (string) $run->type,
'reason' => $reason, 'reason' => $reason,
], ],
run: $cancelledRun, run: $cancelledRun,
@ -190,7 +192,7 @@ public function markInvestigated(OperationRun $run, PlatformUser $actor, string
action: 'platform.system_console.mark_investigated', action: 'platform.system_console.mark_investigated',
metadata: [ metadata: [
'reason' => $reason, 'reason' => $reason,
'operation_type' => $run->canonicalOperationType(), 'operation_type' => (string) $run->type,
], ],
run: $run, run: $run,
); );

View File

@ -65,9 +65,6 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets')) 'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets') ? data_get($sections, '0.summary_payload.finding_report_buckets')
: [], : [],
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
? data_get($sections, '0.summary_payload.canonical_controls')
: [],
'report_count' => 2, 'report_count' => 2,
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0), 'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []), 'highlights' => data_get($sections, '0.render_payload.highlights', []),

View File

@ -55,7 +55,6 @@ private function executiveSummarySection(
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : []; $findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : []; $findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : []; $riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
$canonicalControls = is_array($findingsSummary['canonical_controls'] ?? null) ? $findingsSummary['canonical_controls'] : [];
$openCount = (int) ($findingsSummary['open_count'] ?? 0); $openCount = (int) ($findingsSummary['open_count'] ?? 0);
$findingCount = (int) ($findingsSummary['count'] ?? 0); $findingCount = (int) ($findingsSummary['count'] ?? 0);
@ -71,7 +70,6 @@ private function executiveSummarySection(
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.', $postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
sprintf('%d baseline drift findings remain open.', $driftCount), sprintf('%d baseline drift findings remain open.', $driftCount),
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations), sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
$canonicalControls !== [] ? sprintf('%d canonical controls are referenced by the findings evidence.', count($canonicalControls)) : null,
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)), sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)), sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
])); ]));
@ -98,8 +96,6 @@ private function executiveSummarySection(
'baseline_drift_count' => $driftCount, 'baseline_drift_count' => $driftCount,
'failed_operation_count' => $operationFailures, 'failed_operation_count' => $operationFailures,
'partial_operation_count' => $partialOperations, 'partial_operation_count' => $partialOperations,
'canonical_control_count' => count($canonicalControls),
'canonical_controls' => $canonicalControls,
'risk_acceptance' => $riskAcceptance, 'risk_acceptance' => $riskAcceptance,
], ],
'render_payload' => [ 'render_payload' => [
@ -149,7 +145,6 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
'summary_payload' => [ 'summary_payload' => [
'open_count' => (int) ($summary['open_count'] ?? 0), 'open_count' => (int) ($summary['open_count'] ?? 0),
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [], 'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
], ],
'render_payload' => [ 'render_payload' => [
'entries' => $entries, 'entries' => $entries,
@ -183,7 +178,6 @@ private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): arra
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0), 'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0), 'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0), 'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
], ],
'render_payload' => [ 'render_payload' => [
'entries' => $entries, 'entries' => $entries,
@ -299,20 +293,6 @@ private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null; return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
} }
/**
* @param list<array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function canonicalControlsFromEntries(array $entries): array
{
return collect($entries)
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
->unique(static fn (array $control): string => (string) $control['control_key'])
->values()
->all();
}
/** /**
* @param array<int, TenantReviewCompletenessState> $states * @param array<int, TenantReviewCompletenessState> $states
*/ */

View File

@ -119,11 +119,6 @@ public function providerConnectionCheckUsingConnection(
'connection_type' => $identity->connectionType->value, 'connection_type' => $identity->connectionType->value,
'credential_source' => $identity->credentialSource, 'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId, 'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
),
], ],
]), ]),
); );

View File

@ -99,8 +99,6 @@ enum AuditActionId: string
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed'; case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed'; case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
// Workspace selection / switch events (Spec 107). // Workspace selection / switch events (Spec 107).
case WorkspaceAutoSelected = 'workspace.auto_selected'; case WorkspaceAutoSelected = 'workspace.auto_selected';
case WorkspaceSelected = 'workspace.selected'; case WorkspaceSelected = 'workspace.selected';
@ -236,7 +234,6 @@ private static function labels(): array
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed', self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
'baseline.capture.started' => 'Baseline capture started', 'baseline.capture.started' => 'Baseline capture started',
'baseline.capture.completed' => 'Baseline capture completed', 'baseline.capture.completed' => 'Baseline capture completed',
'baseline.capture.failed' => 'Baseline capture failed', 'baseline.capture.failed' => 'Baseline capture failed',
@ -318,7 +315,6 @@ private static function summaries(): array
self::TenantReviewArchived->value => 'Tenant review archived', self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
]; ];
} }

View File

@ -69,9 +69,6 @@ class Capabilities
public const TENANT_SYNC = 'tenant.sync'; public const TENANT_SYNC = 'tenant.sync';
// Support diagnostics
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
// Inventory // Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run'; public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';

View File

@ -12,7 +12,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Baselines\BaselineSnapshotTruthResolver; use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
@ -167,7 +166,7 @@ public static function forTenant(?Tenant $tenant): self
$latestRun = OperationRun::query() $latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)) ->where('type', 'baseline_compare')
->latest('id') ->latest('id')
->first(); ->first();
@ -458,7 +457,7 @@ public static function forWidget(?Tenant $tenant): self
$latestRun = OperationRun::query() $latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)) ->where('type', 'baseline_compare')
->where('context->baseline_profile_id', (string) $profile->getKey()) ->where('context->baseline_profile_id', (string) $profile->getKey())
->whereNotNull('completed_at') ->whereNotNull('completed_at')
->latest('completed_at') ->latest('completed_at')

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class ArtifactSuitability
{
public function __construct(
public bool $baseline,
public bool $drift,
public bool $finding,
public bool $exception,
public bool $evidence,
public bool $review,
public bool $report,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
foreach (self::requiredKeys() as $key) {
if (! array_key_exists($key, $data)) {
throw new InvalidArgumentException(sprintf('Canonical control artifact suitability is missing [%s].', $key));
}
}
return new self(
baseline: (bool) $data['baseline'],
drift: (bool) $data['drift'],
finding: (bool) $data['finding'],
exception: (bool) $data['exception'],
evidence: (bool) $data['evidence'],
review: (bool) $data['review'],
report: (bool) $data['report'],
);
}
/**
* @return array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool}
*/
public function toArray(): array
{
return [
'baseline' => $this->baseline,
'drift' => $this->drift,
'finding' => $this->finding,
'exception' => $this->exception,
'evidence' => $this->evidence,
'review' => $this->review,
'report' => $this->report,
];
}
/**
* @return list<string>
*/
public static function requiredKeys(): array
{
return ['baseline', 'drift', 'finding', 'exception', 'evidence', 'review', 'report'];
}
}

View File

@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final class CanonicalControlCatalog
{
/**
* @var list<CanonicalControlDefinition>
*/
private array $definitions;
/**
* @var list<MicrosoftSubjectBinding>
*/
private array $microsoftBindings;
/**
* @param list<array<string, mixed>>|null $controls
*/
public function __construct(?array $controls = null)
{
$controls ??= config('canonical_controls.controls', []);
if (! is_array($controls)) {
throw new InvalidArgumentException('Canonical controls config must define a controls array.');
}
$this->definitions = [];
$this->microsoftBindings = [];
foreach ($controls as $control) {
if (! is_array($control)) {
throw new InvalidArgumentException('Canonical control entries must be arrays.');
}
$definition = CanonicalControlDefinition::fromArray($control);
if ($this->find($definition->controlKey) instanceof CanonicalControlDefinition) {
throw new InvalidArgumentException(sprintf('Duplicate canonical control key [%s].', $definition->controlKey));
}
$this->definitions[] = $definition;
$bindings = is_array($control['microsoft_bindings'] ?? null) ? $control['microsoft_bindings'] : [];
foreach ($bindings as $binding) {
if (! is_array($binding)) {
throw new InvalidArgumentException(sprintf('Microsoft bindings for [%s] must be arrays.', $definition->controlKey));
}
$this->microsoftBindings[] = MicrosoftSubjectBinding::fromArray($definition->controlKey, $binding);
}
}
usort(
$this->definitions,
static fn (CanonicalControlDefinition $left, CanonicalControlDefinition $right): int => $left->controlKey <=> $right->controlKey,
);
}
/**
* @return list<CanonicalControlDefinition>
*/
public function all(): array
{
return $this->definitions;
}
/**
* @return list<CanonicalControlDefinition>
*/
public function active(): array
{
return array_values(array_filter(
$this->definitions,
static fn (CanonicalControlDefinition $definition): bool => ! $definition->isRetired(),
));
}
public function find(string $controlKey): ?CanonicalControlDefinition
{
$controlKey = trim($controlKey);
foreach ($this->definitions as $definition) {
if ($definition->controlKey === $controlKey) {
return $definition;
}
}
return null;
}
/**
* @return list<MicrosoftSubjectBinding>
*/
public function microsoftBindings(): array
{
return $this->microsoftBindings;
}
/**
* @return list<MicrosoftSubjectBinding>
*/
public function microsoftBindingsForControl(string $controlKey): array
{
return array_values(array_filter(
$this->microsoftBindings,
static fn (MicrosoftSubjectBinding $binding): bool => $binding->controlKey === trim($controlKey),
));
}
/**
* @return list<array<string, mixed>>
*/
public function listPayload(): array
{
return array_map(
static fn (CanonicalControlDefinition $definition): array => $definition->toArray(),
$this->all(),
);
}
}

View File

@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class CanonicalControlDefinition
{
/**
* @param list<EvidenceArchetype> $evidenceArchetypes
*/
public function __construct(
public string $controlKey,
public string $name,
public string $domainKey,
public string $subdomainKey,
public string $controlClass,
public string $summary,
public string $operatorDescription,
public DetectabilityClass $detectabilityClass,
public EvaluationStrategy $evaluationStrategy,
public array $evidenceArchetypes,
public ArtifactSuitability $artifactSuitability,
public string $historicalStatus = 'active',
) {
foreach ([
'control key' => $this->controlKey,
'name' => $this->name,
'domain key' => $this->domainKey,
'subdomain key' => $this->subdomainKey,
'control class' => $this->controlClass,
'summary' => $this->summary,
'operator description' => $this->operatorDescription,
'historical status' => $this->historicalStatus,
] as $label => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException(sprintf('Canonical control definitions require a non-empty %s.', $label));
}
}
if ($this->controlKey !== mb_strtolower($this->controlKey) || preg_match('/^[a-z][a-z0-9_]*$/', $this->controlKey) !== 1) {
throw new InvalidArgumentException(sprintf('Canonical control key [%s] must be a lowercase provider-neutral slug.', $this->controlKey));
}
if (! in_array($this->historicalStatus, ['active', 'retired'], true)) {
throw new InvalidArgumentException(sprintf('Canonical control [%s] has an unsupported historical status.', $this->controlKey));
}
if ($this->evidenceArchetypes === []) {
throw new InvalidArgumentException(sprintf('Canonical control [%s] must declare at least one evidence archetype.', $this->controlKey));
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
controlKey: (string) ($data['control_key'] ?? ''),
name: (string) ($data['name'] ?? ''),
domainKey: (string) ($data['domain_key'] ?? ''),
subdomainKey: (string) ($data['subdomain_key'] ?? ''),
controlClass: (string) ($data['control_class'] ?? ''),
summary: (string) ($data['summary'] ?? ''),
operatorDescription: (string) ($data['operator_description'] ?? ''),
detectabilityClass: DetectabilityClass::from((string) ($data['detectability_class'] ?? '')),
evaluationStrategy: EvaluationStrategy::from((string) ($data['evaluation_strategy'] ?? '')),
evidenceArchetypes: self::evidenceArchetypes($data['evidence_archetypes'] ?? []),
artifactSuitability: ArtifactSuitability::fromArray(is_array($data['artifact_suitability'] ?? null) ? $data['artifact_suitability'] : []),
historicalStatus: (string) ($data['historical_status'] ?? 'active'),
);
}
/**
* @return array{
* control_key: string,
* name: string,
* domain_key: string,
* subdomain_key: string,
* control_class: string,
* summary: string,
* operator_description: string,
* detectability_class: string,
* evaluation_strategy: string,
* evidence_archetypes: list<string>,
* artifact_suitability: array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool},
* historical_status: string
* }
*/
public function toArray(): array
{
return [
'control_key' => $this->controlKey,
'name' => $this->name,
'domain_key' => $this->domainKey,
'subdomain_key' => $this->subdomainKey,
'control_class' => $this->controlClass,
'summary' => $this->summary,
'operator_description' => $this->operatorDescription,
'detectability_class' => $this->detectabilityClass->value,
'evaluation_strategy' => $this->evaluationStrategy->value,
'evidence_archetypes' => array_map(
static fn (EvidenceArchetype $archetype): string => $archetype->value,
$this->evidenceArchetypes,
),
'artifact_suitability' => $this->artifactSuitability->toArray(),
'historical_status' => $this->historicalStatus,
];
}
public function isRetired(): bool
{
return $this->historicalStatus === 'retired';
}
/**
* @param iterable<mixed> $values
* @return list<EvidenceArchetype>
*/
private static function evidenceArchetypes(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): EvidenceArchetype => EvidenceArchetype::from(trim($value)))
->values()
->all();
}
}

View File

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
final readonly class CanonicalControlResolutionRequest
{
public function __construct(
public string $provider,
public string $consumerContext,
public ?string $subjectFamilyKey = null,
public ?string $workload = null,
public ?string $signalKey = null,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
provider: self::normalize((string) ($data['provider'] ?? '')),
consumerContext: self::normalize((string) ($data['consumer_context'] ?? '')),
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
workload: self::optionalString($data['workload'] ?? null),
signalKey: self::optionalString($data['signal_key'] ?? null),
);
}
public function hasDiscriminator(): bool
{
return $this->subjectFamilyKey !== null || $this->workload !== null || $this->signalKey !== null;
}
/**
* @return array{provider: string, subject_family_key: ?string, workload: ?string, signal_key: ?string, consumer_context: string}
*/
public function bindingContext(): array
{
return [
'provider' => $this->provider,
'subject_family_key' => $this->subjectFamilyKey,
'workload' => $this->workload,
'signal_key' => $this->signalKey,
'consumer_context' => $this->consumerContext,
];
}
private static function optionalString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = self::normalize($value);
return $normalized === '' ? null : $normalized;
}
private static function normalize(string $value): string
{
return trim($value);
}
}

View File

@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
final readonly class CanonicalControlResolutionResult
{
/**
* @param list<string> $candidateControlKeys
*/
private function __construct(
public string $status,
public ?CanonicalControlDefinition $control,
public ?string $reasonCode,
public array $bindingContext,
public array $candidateControlKeys = [],
) {}
public static function resolved(CanonicalControlDefinition $definition): self
{
return new self(
status: 'resolved',
control: $definition,
reasonCode: null,
bindingContext: [],
);
}
public static function unresolved(string $reasonCode, CanonicalControlResolutionRequest $request): self
{
return new self(
status: 'unresolved',
control: null,
reasonCode: $reasonCode,
bindingContext: $request->bindingContext(),
);
}
/**
* @param list<string> $candidateControlKeys
*/
public static function ambiguous(array $candidateControlKeys, CanonicalControlResolutionRequest $request): self
{
sort($candidateControlKeys, SORT_STRING);
return new self(
status: 'ambiguous',
control: null,
reasonCode: 'ambiguous_binding',
bindingContext: $request->bindingContext(),
candidateControlKeys: array_values(array_unique($candidateControlKeys)),
);
}
public function isResolved(): bool
{
return $this->status === 'resolved' && $this->control instanceof CanonicalControlDefinition;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
if ($this->isResolved()) {
return [
'status' => 'resolved',
'control' => $this->control?->toArray(),
];
}
if ($this->status === 'ambiguous') {
return [
'status' => 'ambiguous',
'reason_code' => $this->reasonCode,
'candidate_control_keys' => $this->candidateControlKeys,
'binding_context' => $this->bindingContext,
];
}
return [
'status' => 'unresolved',
'reason_code' => $this->reasonCode,
'binding_context' => $this->bindingContext,
];
}
}

View File

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
final readonly class CanonicalControlResolver
{
/**
* @var list<string>
*/
private const SUPPORTED_CONTEXTS = ['baseline', 'drift', 'finding', 'evidence', 'exception', 'review', 'report'];
public function __construct(
private CanonicalControlCatalog $catalog,
) {}
public function resolve(CanonicalControlResolutionRequest $request): CanonicalControlResolutionResult
{
if ($request->provider !== 'microsoft') {
return CanonicalControlResolutionResult::unresolved('unsupported_provider', $request);
}
if (! in_array($request->consumerContext, self::SUPPORTED_CONTEXTS, true)) {
return CanonicalControlResolutionResult::unresolved('unsupported_consumer_context', $request);
}
if (! $request->hasDiscriminator()) {
return CanonicalControlResolutionResult::unresolved('insufficient_context', $request);
}
$bindings = array_values(array_filter(
$this->catalog->microsoftBindings(),
static fn (MicrosoftSubjectBinding $binding): bool => $binding->matches($request),
));
if ($bindings === []) {
return CanonicalControlResolutionResult::unresolved('missing_binding', $request);
}
$primaryBindings = array_values(array_filter(
$bindings,
static fn (MicrosoftSubjectBinding $binding): bool => $binding->primary,
));
if ($primaryBindings !== []) {
$bindings = $primaryBindings;
}
$candidateControlKeys = array_values(array_unique(array_map(
static fn (MicrosoftSubjectBinding $binding): string => $binding->controlKey,
$bindings,
)));
sort($candidateControlKeys, SORT_STRING);
if (count($candidateControlKeys) !== 1) {
return CanonicalControlResolutionResult::ambiguous($candidateControlKeys, $request);
}
$definition = $this->catalog->find($candidateControlKeys[0]);
if (! $definition instanceof CanonicalControlDefinition) {
return CanonicalControlResolutionResult::unresolved('missing_binding', $request);
}
return CanonicalControlResolutionResult::resolved($definition);
}
}

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
enum DetectabilityClass: string
{
case DirectTechnical = 'direct_technical';
case IndirectTechnical = 'indirect_technical';
case WorkflowAttested = 'workflow_attested';
case ExternalEvidenceOnly = 'external_evidence_only';
}

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
enum EvaluationStrategy: string
{
case StateEvaluated = 'state_evaluated';
case SignalInferred = 'signal_inferred';
case WorkflowConfirmed = 'workflow_confirmed';
case ExternallyAttested = 'externally_attested';
}

View File

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
enum EvidenceArchetype: string
{
case ConfigurationSnapshot = 'configuration_snapshot';
case ExecutionResult = 'execution_result';
case PolicyOrAssignmentSummary = 'policy_or_assignment_summary';
case OperatorAttestation = 'operator_attestation';
case ExternalArtifactReference = 'external_artifact_reference';
}

View File

@ -1,132 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class MicrosoftSubjectBinding
{
/**
* @param list<string> $signalKeys
* @param list<string> $supportedContexts
*/
public function __construct(
public string $controlKey,
public ?string $subjectFamilyKey,
public ?string $workload,
public array $signalKeys,
public array $supportedContexts,
public bool $primary = false,
public ?string $notes = null,
) {
if (trim($this->controlKey) === '') {
throw new InvalidArgumentException('Microsoft subject bindings require a canonical control key.');
}
if ($this->subjectFamilyKey === null && $this->workload === null && $this->signalKeys === []) {
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one discriminator.', $this->controlKey));
}
if ($this->supportedContexts === []) {
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one supported context.', $this->controlKey));
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(string $controlKey, array $data): self
{
return new self(
controlKey: $controlKey,
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
workload: self::optionalString($data['workload'] ?? null),
signalKeys: self::stringList($data['signal_keys'] ?? []),
supportedContexts: self::stringList($data['supported_contexts'] ?? []),
primary: (bool) ($data['primary'] ?? false),
notes: self::optionalString($data['notes'] ?? null),
);
}
public function supportsContext(string $consumerContext): bool
{
return in_array(trim($consumerContext), $this->supportedContexts, true);
}
public function matches(CanonicalControlResolutionRequest $request): bool
{
if ($request->provider !== 'microsoft') {
return false;
}
if (! $this->supportsContext($request->consumerContext)) {
return false;
}
if ($request->subjectFamilyKey !== null && $this->subjectFamilyKey !== $request->subjectFamilyKey) {
return false;
}
if ($request->workload !== null && $this->workload !== $request->workload) {
return false;
}
if ($request->signalKey !== null && ! in_array($request->signalKey, $this->signalKeys, true)) {
return false;
}
return true;
}
/**
* @return array{
* control_key: string,
* provider: string,
* subject_family_key: ?string,
* workload: ?string,
* signal_keys: list<string>,
* supported_contexts: list<string>,
* primary: bool,
* notes: ?string
* }
*/
public function toArray(): array
{
return [
'control_key' => $this->controlKey,
'provider' => 'microsoft',
'subject_family_key' => $this->subjectFamilyKey,
'workload' => $this->workload,
'signal_keys' => $this->signalKeys,
'supported_contexts' => $this->supportedContexts,
'primary' => $this->primary,
'notes' => $this->notes,
];
}
private static function optionalString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -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> * @return list<string>
*/ */
public static function rawValuesForCanonical(string $canonicalCode): array public static function rawValuesForCanonical(string $canonicalCode): array
{ {
$canonicalCode = trim($canonicalCode); return array_values(array_map(
$values = array_values(array_map(
static fn (OperationTypeAlias $alias): string => $alias->rawValue, static fn (OperationTypeAlias $alias): string => $alias->rawValue,
array_filter( array_filter(
self::operationAliases(), 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( return new OperationTypeResolution(
rawValue: $operationType, rawValue: $operationType,
canonical: new CanonicalOperationType( canonical: new CanonicalOperationType(
@ -289,29 +262,29 @@ private static function operationAliases(): array
{ {
return [ return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true), 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.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', '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('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', '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('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('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('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.update', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', '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.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_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', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), 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', 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_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', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', '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('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', '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.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true), new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', '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('alerts.deliver', 'alerts.deliver', 'canonical', true),
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true), new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
new OperationTypeAlias('baseline.compare', 'baseline.compare', '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_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', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare 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', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new 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('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_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true), new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),

View File

@ -108,7 +108,7 @@ public static function index(
} }
if (is_string($operationType) && $operationType !== '') { if (is_string($operationType) && $operationType !== '') {
$parameters['tableFilters']['type']['value'] = OperationCatalog::canonicalCode($operationType); $parameters['tableFilters']['type']['value'] = $operationType;
} }
return route('admin.operations.index', $parameters); 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; $providerConnectionId = $context['provider_connection_id'] ?? null;
$canonicalType = $run->canonicalOperationType();
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) { if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); $links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], 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); $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); $links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$policyId = $context['policy_id'] ?? null; $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); $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); $links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
} }
if ($canonicalType === 'baseline.capture') { if ($run->type === 'baseline_capture') {
$snapshotId = data_get($context, 'result.snapshot_id'); $snapshotId = data_get($context, 'result.snapshot_id');
if (is_numeric($snapshotId)) { 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); $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null; $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); $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); $links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$restoreRunId = $context['restore_run_id'] ?? null; $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() $snapshot = EvidenceSnapshot::query()
->where('operation_run_id', (int) $run->getKey()) ->where('operation_run_id', (int) $run->getKey())
->latest('id') ->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() $review = TenantReview::query()
->where('operation_run_id', (int) $run->getKey()) ->where('operation_run_id', (int) $run->getKey())
->latest('id') ->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() $pack = ReviewPack::query()
->where('operation_run_id', (int) $run->getKey()) ->where('operation_run_id', (int) $run->getKey())
->latest('id') ->latest('id')

View File

@ -4,16 +4,17 @@
enum OperationRunType: string enum OperationRunType: string
{ {
case BaselineCapture = 'baseline.capture'; case BaselineCapture = 'baseline_capture';
case BaselineCompare = 'baseline.compare'; case BaselineCompare = 'baseline_compare';
case InventorySync = 'inventory.sync'; case InventorySync = 'inventory_sync';
case PolicySync = 'policy.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 BackupSetUpdate = 'backup_set.update';
case BackupScheduleExecute = 'backup.schedule.execute'; case BackupScheduleExecute = 'backup_schedule_run';
case BackupScheduleRetention = 'backup.schedule.retention'; case BackupScheduleRetention = 'backup_schedule_retention';
case BackupSchedulePurge = 'backup.schedule.purge'; case BackupSchedulePurge = 'backup_schedule_purge';
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync'; case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
case RestoreExecute = 'restore.execute'; case RestoreExecute = 'restore.execute';
case EntraAdminRolesScan = 'entra.admin_roles.scan'; case EntraAdminRolesScan = 'entra.admin_roles.scan';
case ReviewPackGenerate = 'tenant.review_pack.generate'; case ReviewPackGenerate = 'tenant.review_pack.generate';
@ -28,6 +29,24 @@ public static function values(): array
public function canonicalCode(): string 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,
};
} }
} }

View File

@ -4,7 +4,6 @@
namespace App\Support\Operations; namespace App\Support\Operations;
use App\Support\OperationCatalog;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
final class OperationLifecyclePolicy final class OperationLifecyclePolicy
@ -44,8 +43,7 @@ public function definition(string $operationType): ?array
return null; return null;
} }
$canonicalType = OperationCatalog::canonicalCode($operationType); $definition = $this->coveredTypes()[$operationType] ?? null;
$definition = $this->coveredTypes()[$canonicalType] ?? null;
if (! is_array($definition)) { if (! is_array($definition)) {
return null; return null;

View File

@ -4,7 +4,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationCatalog;
final class OperationRunCapabilityResolver final class OperationRunCapabilityResolver
{ {
@ -15,18 +14,18 @@ public function requiredCapabilityForRun(OperationRun $run): ?string
public function requiredCapabilityForType(string $operationType): ?string public function requiredCapabilityForType(string $operationType): ?string
{ {
$operationType = OperationCatalog::canonicalCode($operationType); $operationType = trim($operationType);
if ($operationType === '') { if ($operationType === '') {
return null; return null;
} }
return match ($operationType) { return match ($operationType) {
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, 'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'directory.groups.sync' => Capabilities::TENANT_SYNC, 'entra_group_sync' => Capabilities::TENANT_SYNC,
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, 'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
'restore.execute' => Capabilities::TENANT_MANAGE, '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, 'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
@ -41,15 +40,15 @@ public function requiredCapabilityForType(string $operationType): ?string
public function requiredExecutionCapabilityForType(string $operationType): ?string public function requiredExecutionCapabilityForType(string $operationType): ?string
{ {
$operationType = OperationCatalog::canonicalCode($operationType); $operationType = trim($operationType);
if ($operationType === '') { if ($operationType === '') {
return null; return null;
} }
return match ($operationType) { return match ($operationType) {
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN, 'provider.connection.check', 'provider.inventory.sync', 'provider.compliance.snapshot' => Capabilities::PROVIDER_RUN,
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC, 'policy.sync', 'policy.sync_one', 'tenant.sync' => Capabilities::TENANT_SYNC,
'policy.delete' => Capabilities::TENANT_MANAGE, 'policy.delete' => Capabilities::TENANT_MANAGE,
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE, 'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,

View File

@ -567,7 +567,7 @@ private static function terminalSupportingLines(OperationRun $run): array
private static function baselineTruthChangeLine(OperationRun $run): ?string private static function baselineTruthChangeLine(OperationRun $run): ?string
{ {
if ($run->canonicalOperationType() !== 'baseline.capture') { if ((string) $run->type !== 'baseline_capture') {
return null; return null;
} }

View File

@ -1,194 +0,0 @@
<?php
namespace App\Support\Providers\Boundary;
use InvalidArgumentException;
final class ProviderBoundaryCatalog
{
public const string STATUS_ALLOWED = 'allowed';
public const string STATUS_REVIEW_REQUIRED = 'review_required';
public const string STATUS_BLOCKED = 'blocked';
public const string VIOLATION_NONE = 'none';
public const string VIOLATION_PLATFORM_CORE_PROVIDER_LEAK = 'platform_core_provider_leak';
public const string VIOLATION_UNDECLARED_EXCEPTION = 'undeclared_exception';
public const string VIOLATION_MISSING_PROVIDER_BINDING = 'missing_provider_binding';
public const string VIOLATION_PROVIDER_BINDING_AS_PRIMARY_TRUTH = 'provider_binding_as_primary_truth';
/**
* @return array<string, ProviderBoundarySeam>
*/
public function all(): array
{
$seams = config('provider_boundaries.seams', []);
if (! is_array($seams)) {
throw new InvalidArgumentException('Provider boundary seam catalog must be an array.');
}
$catalog = [];
foreach ($seams as $key => $attributes) {
if (! is_string($key) || ! is_array($attributes)) {
throw new InvalidArgumentException('Provider boundary seam catalog entries must be keyed arrays.');
}
$catalog[$key] = ProviderBoundarySeam::fromConfig($key, $attributes);
}
ksort($catalog);
return $catalog;
}
public function get(string $key): ProviderBoundarySeam
{
$normalizedKey = trim($key);
$seam = $this->all()[$normalizedKey] ?? null;
if (! $seam instanceof ProviderBoundarySeam) {
throw new InvalidArgumentException("Unknown provider boundary seam: {$normalizedKey}");
}
return $seam;
}
public function has(string $key): bool
{
return array_key_exists(trim($key), $this->all());
}
/**
* @return array{
* status: string,
* seam_key: string,
* file_path: string,
* violation_code: string,
* message: string,
* suggested_follow_up: string
* }
*/
public function evaluateChange(
string $seamKey,
string $filePath,
ProviderBoundaryOwner|string $proposedOwner,
array $providerSpecificTerms = [],
bool $introducesNewBinding = false,
): array {
$seam = $this->get($seamKey);
$owner = is_string($proposedOwner)
? ProviderBoundaryOwner::tryFrom($proposedOwner)
: $proposedOwner;
if (! $owner instanceof ProviderBoundaryOwner) {
throw new InvalidArgumentException('Proposed provider boundary owner is invalid.');
}
$providerSpecificTerms = $this->normalizeTerms($providerSpecificTerms);
if ($introducesNewBinding && $seam->isPlatformCore() && $owner === ProviderBoundaryOwner::PlatformCore) {
return $this->result(
status: self::STATUS_BLOCKED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_PROVIDER_BINDING_AS_PRIMARY_TRUTH,
message: 'Provider binding metadata must stay explicit and secondary to the platform-core operation definition.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
);
}
if ($seam->isProviderOwned()) {
return $this->result(
status: self::STATUS_ALLOWED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_NONE,
message: 'Provider-specific semantics are allowed inside this provider-owned seam.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_NONE,
);
}
if ($providerSpecificTerms === []) {
return $this->result(
status: self::STATUS_ALLOWED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_NONE,
message: 'The platform-core seam does not introduce provider-specific terms.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_NONE,
);
}
$undocumentedTerms = array_values(array_filter(
$providerSpecificTerms,
static fn (string $term): bool => ! $seam->documentsProviderSemantic($term),
));
if ($undocumentedTerms !== []) {
return $this->result(
status: self::STATUS_BLOCKED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_PLATFORM_CORE_PROVIDER_LEAK,
message: 'Platform-core seam contains undocumented provider-specific terms: '.implode(', ', $undocumentedTerms).'.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_SPEC,
);
}
return $this->result(
status: self::STATUS_REVIEW_REQUIRED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_NONE,
message: 'Platform-core seam relies on documented current-release provider exception metadata.',
suggestedFollowUp: $seam->followUpAction,
);
}
/**
* @param array<mixed> $terms
* @return list<string>
*/
private function normalizeTerms(array $terms): array
{
return array_values(array_filter(
array_map(static fn (mixed $term): string => trim((string) $term), $terms),
static fn (string $term): bool => $term !== '',
));
}
/**
* @return array{
* status: string,
* seam_key: string,
* file_path: string,
* violation_code: string,
* message: string,
* suggested_follow_up: string
* }
*/
private function result(
string $status,
ProviderBoundarySeam $seam,
string $filePath,
string $violationCode,
string $message,
string $suggestedFollowUp,
): array {
return [
'status' => $status,
'seam_key' => $seam->key,
'file_path' => $filePath,
'violation_code' => $violationCode,
'message' => $message,
'suggested_follow_up' => $suggestedFollowUp,
];
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Support\Providers\Boundary;
enum ProviderBoundaryOwner: string
{
case ProviderOwned = 'provider_owned';
case PlatformCore = 'platform_core';
/**
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -1,149 +0,0 @@
<?php
namespace App\Support\Providers\Boundary;
use InvalidArgumentException;
final class ProviderBoundarySeam
{
public const string FOLLOW_UP_NONE = 'none';
public const string FOLLOW_UP_DOCUMENT_IN_FEATURE = 'document-in-feature';
public const string FOLLOW_UP_SPEC = 'follow-up-spec';
/**
* @param list<string> $implementationPaths
* @param list<string> $neutralTerms
* @param list<string> $retainedProviderSemantics
*/
public function __construct(
public readonly string $key,
public readonly ProviderBoundaryOwner $owner,
public readonly string $description,
public readonly array $implementationPaths,
public readonly array $neutralTerms,
public readonly array $retainedProviderSemantics,
public readonly string $followUpAction,
) {
$this->validate();
}
/**
* @param array{
* owner?: string,
* description?: string,
* implementation_paths?: list<string>,
* neutral_terms?: list<string>,
* retained_provider_semantics?: list<string>,
* follow_up_action?: string
* } $attributes
*/
public static function fromConfig(string $key, array $attributes): self
{
$owner = ProviderBoundaryOwner::tryFrom((string) ($attributes['owner'] ?? ''));
if (! $owner instanceof ProviderBoundaryOwner) {
throw new InvalidArgumentException("Provider boundary seam [{$key}] has an invalid owner.");
}
return new self(
key: $key,
owner: $owner,
description: (string) ($attributes['description'] ?? ''),
implementationPaths: self::stringList($attributes['implementation_paths'] ?? []),
neutralTerms: self::stringList($attributes['neutral_terms'] ?? []),
retainedProviderSemantics: self::stringList($attributes['retained_provider_semantics'] ?? []),
followUpAction: (string) ($attributes['follow_up_action'] ?? self::FOLLOW_UP_NONE),
);
}
public function isProviderOwned(): bool
{
return $this->owner === ProviderBoundaryOwner::ProviderOwned;
}
public function isPlatformCore(): bool
{
return $this->owner === ProviderBoundaryOwner::PlatformCore;
}
public function retainsProviderSemantics(): bool
{
return $this->retainedProviderSemantics !== [];
}
public function documentsProviderSemantic(string $term): bool
{
return in_array($term, $this->retainedProviderSemantics, true);
}
public function coversPath(string $path): bool
{
$normalizedPath = $this->normalizePath($path);
foreach ($this->implementationPaths as $implementationPath) {
if ($normalizedPath === $this->normalizePath($implementationPath)) {
return true;
}
}
return false;
}
/**
* @param array<mixed> $values
* @return list<string>
*/
private static function stringList(array $values): array
{
return array_values(array_filter(
array_map(static fn (mixed $value): string => trim((string) $value), $values),
static fn (string $value): bool => $value !== '',
));
}
private function validate(): void
{
if (trim($this->key) === '') {
throw new InvalidArgumentException('Provider boundary seam key cannot be empty.');
}
if (trim($this->description) === '') {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] must include a description.");
}
if ($this->implementationPaths === []) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] must include implementation paths.");
}
if ($this->isPlatformCore() && $this->neutralTerms === []) {
throw new InvalidArgumentException("Platform-core provider boundary seam [{$this->key}] must include neutral terms.");
}
if ($this->retainsProviderSemantics() && $this->followUpAction === self::FOLLOW_UP_NONE) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] retains provider semantics without a follow-up action.");
}
if (! in_array($this->followUpAction, $this->validFollowUpActions(), true)) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] has an invalid follow-up action.");
}
}
/**
* @return list<string>
*/
private function validFollowUpActions(): array
{
return [
self::FOLLOW_UP_NONE,
self::FOLLOW_UP_DOCUMENT_IN_FEATURE,
self::FOLLOW_UP_SPEC,
];
}
private function normalizePath(string $path): string
{
return trim(str_replace('\\', '/', $path), '/');
}
}

View File

@ -34,8 +34,6 @@ final class ProviderReasonCodes
public const string ProviderConnectionReviewRequired = 'provider_connection_review_required'; public const string ProviderConnectionReviewRequired = 'provider_connection_review_required';
public const string ProviderBindingUnsupported = 'provider_binding_unsupported';
public const string ProviderAuthFailed = 'provider_auth_failed'; public const string ProviderAuthFailed = 'provider_auth_failed';
public const string ProviderPermissionMissing = 'provider_permission_missing'; public const string ProviderPermissionMissing = 'provider_permission_missing';
@ -79,7 +77,6 @@ public static function all(): array
self::ProviderConsentFailed, self::ProviderConsentFailed,
self::ProviderConsentRevoked, self::ProviderConsentRevoked,
self::ProviderConnectionReviewRequired, self::ProviderConnectionReviewRequired,
self::ProviderBindingUnsupported,
self::ProviderAuthFailed, self::ProviderAuthFailed,
self::ProviderPermissionMissing, self::ProviderPermissionMissing,
self::ProviderPermissionDenied, self::ProviderPermissionDenied,
@ -142,7 +139,6 @@ public static function platformReasonFamily(string $reasonCode): PlatformReasonF
self::ProviderAuthFailed => PlatformReasonFamily::Availability, self::ProviderAuthFailed => PlatformReasonFamily::Availability,
self::ProviderConnectionTypeInvalid, self::ProviderConnectionTypeInvalid,
self::TenantTargetMismatch, self::TenantTargetMismatch,
self::ProviderBindingUnsupported,
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility, self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
default => PlatformReasonFamily::Prerequisite, default => PlatformReasonFamily::Prerequisite,
}; };

View File

@ -141,13 +141,6 @@ public function translate(string $reasonCode, string $surface = 'detail', array
actionability: 'prerequisite_missing', actionability: 'prerequisite_missing',
nextSteps: $nextSteps, nextSteps: $nextSteps,
), ),
ProviderReasonCodes::ProviderBindingUnsupported => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider binding unsupported',
shortExplanation: 'This operation does not have an explicit provider binding for the selected provider.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderAuthFailed => $this->envelope( ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
reasonCode: $normalizedCode, reasonCode: $normalizedCode,
operatorLabel: 'Provider authentication failed', operatorLabel: 'Provider authentication failed',
@ -291,8 +284,7 @@ private function nextStepsFor(
ProviderReasonCodes::TenantTargetMismatch, ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing, ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete, ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::ProviderConnectionReviewRequired, ProviderReasonCodes::ProviderConnectionReviewRequired => [
ProviderReasonCodes::ProviderBindingUnsupported => [
NextStepOption::link( NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections', label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection destination: $connection instanceof ProviderConnection

View File

@ -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',
};
}
}

View File

@ -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.');
}
}
}

View File

@ -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,
];
}
}

View File

@ -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,
];
}
}

View File

@ -15,35 +15,6 @@ public static function protectedValueNote(): string
return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.'; return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.';
} }
public static function supportDiagnosticsNote(): string
{
return 'Support diagnostics are default-redacted. Secrets, credentials, raw provider payloads, full response bodies, and unrestricted log excerpts are intentionally excluded.';
}
/**
* @return list<array{path: ?string, reason: string, replacement_text: string}>
*/
public static function supportDiagnosticsMarkers(): array
{
return [
[
'path' => 'provider_connection.credential',
'reason' => 'credential',
'replacement_text' => '[REDACTED]',
],
[
'path' => 'stored_reports.payload',
'reason' => 'raw_payload',
'replacement_text' => '[REDACTED]',
],
[
'path' => 'audit_logs.metadata.raw',
'reason' => 'restricted_log_excerpt',
'replacement_text' => '[REDACTED]',
],
];
}
public static function noteForPolicyVersion(PolicyVersion $version): ?string public static function noteForPolicyVersion(PolicyVersion $version): ?string
{ {
if (self::fingerprintCount($version->secret_fingerprints) > 0) { if (self::fingerprintCount($version->secret_fingerprints) > 0) {

View File

@ -1,942 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportDiagnostics;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
use App\Support\Providers\ProviderReasonTranslator;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\RedactionIntegrity;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
final class SupportDiagnosticBundleBuilder
{
private const SECTION_ORDER = [
'overview',
'provider_connection',
'operation_context',
'findings',
'stored_reports',
'tenant_review',
'review_pack',
'audit_history',
];
public function __construct(
private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder,
private readonly ProviderReasonTranslator $providerReasonTranslator,
private readonly RelatedNavigationResolver $relatedNavigationResolver,
) {}
/**
* @return array<string, mixed>
*/
public function forTenant(Tenant $tenant, ?User $actor = null): array
{
$tenant->loadMissing('workspace');
$workspace = $tenant->workspace;
$providerConnection = $this->tenantProviderConnection($tenant);
$operationRun = $this->tenantOperationRun($tenant);
$findings = $this->tenantFindings($tenant);
$storedReports = $this->tenantStoredReports($tenant);
$tenantReview = $this->tenantReview($tenant);
$reviewPack = $this->tenantReviewPack($tenant, $tenantReview);
$auditLogs = $this->tenantAuditLogs($tenant);
return $this->bundle(
contextType: 'tenant',
workspace: $workspace,
tenant: $tenant,
operationRun: $operationRun,
headline: 'Support diagnostics for '.$tenant->name,
dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings),
sections: [
$this->overviewSection($workspace, $tenant, $operationRun),
$this->providerConnectionSection($providerConnection, $tenant),
$this->operationContextSection($operationRun, $tenant),
$this->findingsSection($findings, $tenant),
$this->storedReportsSection($storedReports),
$this->tenantReviewSection($tenantReview, $tenant),
$this->reviewPackSection($reviewPack, $tenant),
$this->auditHistorySection($auditLogs),
],
);
}
/**
* @return array<string, mixed>
*/
public function forOperationRun(OperationRun $run, ?User $actor = null): array
{
$run->loadMissing(['workspace', 'tenant']);
$workspace = $run->workspace;
$tenant = $run->tenant;
$providerConnection = $tenant instanceof Tenant
? $this->operationProviderConnection($run, $tenant)
: null;
$findings = $tenant instanceof Tenant ? $this->operationFindings($run, $tenant) : collect();
$storedReports = $tenant instanceof Tenant ? $this->tenantStoredReports($tenant) : collect();
$tenantReview = $tenant instanceof Tenant ? $this->operationTenantReview($run, $tenant) : null;
$reviewPack = $tenant instanceof Tenant ? $this->operationReviewPack($run, $tenant, $tenantReview) : null;
$auditLogs = $this->operationAuditLogs($run);
$runSummary = $this->runSummaryBuilder->build($run);
$runSummaryArray = $runSummary?->toArray();
return $this->bundle(
contextType: 'operation_run',
workspace: $workspace,
tenant: $tenant,
operationRun: $run,
headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics',
dominantIssue: (string) data_get(
$runSummaryArray,
'dominantCause.explanation',
$this->operationDominantIssue($run),
),
sections: [
$this->overviewSection($workspace, $tenant, $run),
$this->providerConnectionSection($providerConnection, $tenant, $run),
$this->operationContextSection($run, $tenant, $runSummaryArray),
$this->findingsSection($findings, $tenant),
$this->storedReportsSection($storedReports),
$this->tenantReviewSection($tenantReview, $tenant),
$this->reviewPackSection($reviewPack, $tenant),
$this->auditHistorySection($auditLogs),
],
);
}
/**
* @param list<array<string, mixed>> $sections
* @return array<string, mixed>
*/
private function bundle(
string $contextType,
?Workspace $workspace,
?Tenant $tenant,
?OperationRun $operationRun,
string $headline,
string $dominantIssue,
array $sections,
): array {
$sections = $this->sortSections($sections);
$redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers();
return [
'context_type' => $contextType,
'context' => [
'type' => $contextType,
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
'tenant_id' => $tenant instanceof Tenant ? (int) $tenant->getKey() : null,
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
'tenant_label' => $tenant?->name,
'workspace_label' => $workspace?->name,
],
'workspace' => $workspace instanceof Workspace ? [
'record_id' => (string) $workspace->getKey(),
'label' => $workspace->name,
] : null,
'tenant' => $tenant instanceof Tenant ? $this->tenantReference($tenant) : null,
'operation_run' => $operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null,
'headline' => $headline,
'dominant_issue' => $dominantIssue,
'freshness_state' => $this->freshnessState($sections),
'redaction_mode' => 'default_redacted',
'summary' => [
'headline' => $headline,
'dominant_issue' => $dominantIssue,
'freshness_state' => $this->freshnessState($sections),
'completeness_note' => $this->completenessNote($sections),
'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(),
'generated_from' => 'derived_existing_truth',
],
'sections' => $sections,
'redaction' => [
'mode' => 'default_redacted',
'markers' => $redactionMarkers,
],
'notes' => array_values(array_filter([
RedactionIntegrity::supportDiagnosticsNote(),
$this->completenessNote($sections),
])),
];
}
private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection
{
return ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->orderByDesc('is_default')
->orderByDesc('last_health_check_at')
->orderByDesc('id')
->first();
}
private function operationProviderConnection(OperationRun $run, Tenant $tenant): ?ProviderConnection
{
$context = is_array($run->context) ? $run->context : [];
$providerConnectionId = data_get($context, 'provider_connection_id');
if (is_numeric($providerConnectionId)) {
$connection = ProviderConnection::query()
->whereKey((int) $providerConnectionId)
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->first();
if ($connection instanceof ProviderConnection) {
return $connection;
}
}
return $this->tenantProviderConnection($tenant);
}
private function tenantOperationRun(Tenant $tenant): ?OperationRun
{
return OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->orderByRaw('completed_at IS NULL')
->orderByDesc('completed_at')
->orderByDesc('created_at')
->orderByDesc('id')
->first();
}
/**
* @return Collection<int, Finding>
*/
private function tenantFindings(Tenant $tenant): Collection
{
return Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->whereIn('status', Finding::openStatusesForQuery())
->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END")
->orderByDesc('last_seen_at')
->orderBy('id')
->limit(3)
->get();
}
/**
* @return Collection<int, Finding>
*/
private function operationFindings(OperationRun $run, Tenant $tenant): Collection
{
$runBound = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->where(function (Builder $query) use ($run): void {
$query
->where('current_operation_run_id', (int) $run->getKey())
->orWhere('baseline_operation_run_id', (int) $run->getKey());
})
->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END")
->orderByDesc('last_seen_at')
->orderBy('id')
->limit(3)
->get();
return $runBound->isNotEmpty() ? $runBound : $this->tenantFindings($tenant);
}
/**
* @return Collection<int, StoredReport>
*/
private function tenantStoredReports(Tenant $tenant): Collection
{
return StoredReport::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->orderByDesc('updated_at')
->orderByDesc('id')
->limit(3)
->get();
}
private function tenantReview(Tenant $tenant): ?TenantReview
{
return TenantReview::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->orderByDesc('generated_at')
->orderByDesc('id')
->first();
}
private function operationTenantReview(OperationRun $run, Tenant $tenant): ?TenantReview
{
$review = TenantReview::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->where('operation_run_id', (int) $run->getKey())
->orderByDesc('generated_at')
->orderByDesc('id')
->first();
return $review instanceof TenantReview ? $review : $this->tenantReview($tenant);
}
private function tenantReviewPack(Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack
{
if ($tenantReview instanceof TenantReview && is_numeric($tenantReview->current_export_review_pack_id)) {
$pack = ReviewPack::query()
->whereKey((int) $tenantReview->current_export_review_pack_id)
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->first();
if ($pack instanceof ReviewPack) {
return $pack;
}
}
return ReviewPack::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->orderByDesc('generated_at')
->orderByDesc('id')
->first();
}
private function operationReviewPack(OperationRun $run, Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack
{
$pack = ReviewPack::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->where('operation_run_id', (int) $run->getKey())
->orderByDesc('generated_at')
->orderByDesc('id')
->first();
return $pack instanceof ReviewPack ? $pack : $this->tenantReviewPack($tenant, $tenantReview);
}
/**
* @return Collection<int, AuditLog>
*/
private function tenantAuditLogs(Tenant $tenant): Collection
{
return AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->latestFirst()
->limit(5)
->get();
}
/**
* @return Collection<int, AuditLog>
*/
private function operationAuditLogs(OperationRun $run): Collection
{
return AuditLog::query()
->where('workspace_id', (int) $run->workspace_id)
->where(function (Builder $query) use ($run): void {
$query
->where('operation_run_id', (int) $run->getKey())
->orWhere(function (Builder $targetQuery) use ($run): void {
$targetQuery
->where('resource_type', 'operation_run')
->where('resource_id', (string) $run->getKey());
});
})
->latestFirst()
->limit(5)
->get();
}
private function tenantDominantIssue(?ProviderConnection $providerConnection, ?OperationRun $operationRun, Collection $findings): string
{
if ($providerConnection instanceof ProviderConnection) {
$providerIssue = $this->providerIssue($providerConnection);
if ($providerIssue !== null) {
return $providerIssue;
}
}
if ($operationRun instanceof OperationRun && in_array((string) $operationRun->outcome, ['failed', 'blocked', 'partially_succeeded'], true)) {
return $this->operationDominantIssue($operationRun);
}
if ($findings->isNotEmpty()) {
return 'Open findings need review before support can treat this tenant as quiet.';
}
return 'No dominant support blocker is currently visible from the selected tenant context.';
}
private function operationDominantIssue(OperationRun $run): string
{
$failure = collect(is_array($run->failure_summary) ? $run->failure_summary : [])
->first(static fn (mixed $item): bool => is_array($item) && trim((string) ($item['message'] ?? '')) !== '');
if (is_array($failure)) {
return trim((string) $failure['message']);
}
return match ((string) $run->outcome) {
'failed' => 'The operation failed and needs follow-up.',
'blocked' => 'The operation was blocked by a prerequisite or policy condition.',
'partially_succeeded' => 'The operation completed with degraded or partial results.',
default => 'The operation is available for support review.',
};
}
private function providerIssue(ProviderConnection $connection): ?string
{
$reasonCode = trim((string) $connection->last_error_reason_code);
if ($reasonCode !== '') {
$envelope = $this->providerReasonTranslator->translate($reasonCode, 'support_diagnostics', [
'tenant' => $connection->tenant,
'connection' => $connection,
]);
if ($envelope !== null) {
return $envelope->operatorLabel.': '.$envelope->shortExplanation;
}
}
$surface = ProviderConnectionSurfaceSummary::forConnection($connection);
if ($surface->readinessSummary !== 'Ready') {
return 'Provider connection '.$surface->readinessSummary.'.';
}
return null;
}
private function overviewSection(?Workspace $workspace, ?Tenant $tenant, ?OperationRun $operationRun): array
{
$references = array_values(array_filter([
$tenant instanceof Tenant ? $this->tenantReference($tenant) : null,
$operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null,
]));
return $this->section(
key: 'overview',
label: 'Overview',
availability: $references === [] ? 'missing' : 'available',
summary: $tenant instanceof Tenant
? 'Workspace and tenant scope resolved before support diagnostics were composed.'
: 'Workspace scope resolved; no tenant context is attached to this operation.',
references: $references,
);
}
private function providerConnectionSection(?ProviderConnection $connection, ?Tenant $tenant, ?OperationRun $run = null): array
{
if (! $connection instanceof ProviderConnection) {
return $this->section(
key: 'provider_connection',
label: 'Provider connection',
availability: 'missing',
summary: 'No provider connection was found for this support context.',
references: [
$this->missingReference('provider_connection', 'Provider connection not observed', 'Open provider connection'),
],
redactionMarkers: [
[
'path' => 'provider_connection.credential',
'reason' => 'credential',
'replacement_text' => '[REDACTED]',
],
],
);
}
$surface = ProviderConnectionSurfaceSummary::forConnection($connection);
$providerIssue = $this->providerIssue($connection);
return $this->section(
key: 'provider_connection',
label: 'Provider connection',
availability: $surface->readinessSummary === 'Ready' ? 'available' : 'stale',
summary: $providerIssue ?? sprintf(
'%s provider connection is %s.',
ucfirst($surface->provider),
strtolower($surface->readinessSummary),
),
freshnessNote: $this->freshnessNote($connection->last_health_check_at, 'Last health check'),
references: [
$this->modelReference(
type: 'provider_connection',
record: $connection,
label: $connection->display_name ?: 'Provider connection #'.$connection->getKey(),
actionLabel: 'Open provider connection',
url: ProviderConnectionResource::getUrl('view', ['record' => $connection], panel: 'admin'),
freshnessAt: $connection->last_health_check_at,
),
],
redactionMarkers: [
[
'path' => 'provider_connection.credential',
'reason' => 'credential',
'replacement_text' => '[REDACTED]',
],
],
);
}
private function operationContextSection(?OperationRun $operationRun, ?Tenant $tenant, ?array $runSummary = null): array
{
if (! $operationRun instanceof OperationRun) {
return $this->section(
key: 'operation_context',
label: 'Operation context',
availability: 'missing',
summary: 'No recent operation context was found for this support context.',
references: [
$this->missingReference('operation_run', 'Operation not yet observed', OperationRunLinks::openLabel()),
],
);
}
$runSummary ??= $this->runSummaryBuilder->build($operationRun)?->toArray();
return $this->section(
key: 'operation_context',
label: 'Operation context',
availability: 'available',
summary: (string) ($runSummary['headline'] ?? $this->operationDominantIssue($operationRun)),
freshnessNote: $this->freshnessNote($operationRun->completed_at ?? $operationRun->updated_at, 'Last run update'),
references: [
$this->operationReference($operationRun, $tenant),
...$this->operationRelatedReferences($operationRun, $tenant),
],
);
}
private function findingsSection(Collection $findings, ?Tenant $tenant): array
{
if ($findings->isEmpty()) {
return $this->section(
key: 'findings',
label: 'Findings',
availability: 'missing',
summary: 'No open or run-related findings were found for this support context.',
references: [
$this->missingReference('finding', 'No relevant finding observed', 'Open finding'),
],
);
}
return $this->section(
key: 'findings',
label: 'Findings',
availability: 'available',
summary: sprintf('%d finding reference(s) are included for support triage.', $findings->count()),
freshnessNote: $this->freshnessNote($findings->max('last_seen_at'), 'Latest finding'),
references: $findings
->map(fn (Finding $finding): array => $this->modelReference(
type: 'finding',
record: $finding,
label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()),
actionLabel: 'Open finding',
url: $tenant instanceof Tenant
? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)
: null,
freshnessAt: $finding->last_seen_at,
))
->values()
->all(),
);
}
private function storedReportsSection(Collection $reports): array
{
if ($reports->isEmpty()) {
return $this->section(
key: 'stored_reports',
label: 'Stored reports',
availability: 'missing',
summary: 'No stored report identity was found for this support context.',
references: [
$this->missingReference('stored_report', 'Stored report not yet observed', 'Review stored report identity'),
],
redactionMarkers: [
[
'path' => 'stored_reports.payload',
'reason' => 'raw_payload',
'replacement_text' => '[REDACTED]',
],
],
);
}
return $this->section(
key: 'stored_reports',
label: 'Stored reports',
availability: 'redacted',
summary: sprintf('%d stored report identity reference(s) are included without raw report payloads.', $reports->count()),
freshnessNote: $this->freshnessNote($reports->max('updated_at'), 'Latest stored report'),
references: $reports
->map(fn (StoredReport $report): array => $this->modelReference(
type: 'stored_report',
record: $report,
label: str_replace('_', ' ', (string) $report->report_type).' report #'.$report->getKey(),
actionLabel: 'Review stored report identity',
url: null,
freshnessAt: $report->updated_at,
))
->values()
->all(),
redactionMarkers: [
[
'path' => 'stored_reports.payload',
'reason' => 'raw_payload',
'replacement_text' => '[REDACTED]',
],
],
);
}
private function tenantReviewSection(?TenantReview $review, ?Tenant $tenant): array
{
if (! $review instanceof TenantReview) {
return $this->section(
key: 'tenant_review',
label: 'Tenant review',
availability: 'missing',
summary: 'No tenant review was found for this support context.',
references: [
$this->missingReference('tenant_review', 'Tenant review not yet observed', 'Open tenant review'),
],
);
}
return $this->section(
key: 'tenant_review',
label: 'Tenant review',
availability: 'available',
summary: sprintf('Latest tenant review is %s with %s completeness.', (string) $review->status, (string) $review->completeness_state),
freshnessNote: $this->freshnessNote($review->generated_at, 'Generated'),
references: [
$this->modelReference(
type: 'tenant_review',
record: $review,
label: 'Tenant review #'.$review->getKey(),
actionLabel: 'Open tenant review',
url: $tenant instanceof Tenant
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)
: null,
freshnessAt: $review->generated_at,
),
],
);
}
private function reviewPackSection(?ReviewPack $pack, ?Tenant $tenant): array
{
if (! $pack instanceof ReviewPack) {
return $this->section(
key: 'review_pack',
label: 'Review pack',
availability: 'missing',
summary: 'No review pack was found for this support context.',
references: [
$this->missingReference('review_pack', 'Review pack not yet observed', 'Open review pack'),
],
);
}
return $this->section(
key: 'review_pack',
label: 'Review pack',
availability: $pack->isExpired() ? 'stale' : 'available',
summary: sprintf('Review pack #%d is %s.', (int) $pack->getKey(), (string) $pack->status),
freshnessNote: $this->freshnessNote($pack->generated_at, 'Generated'),
references: [
$this->modelReference(
type: 'review_pack',
record: $pack,
label: 'Review pack #'.$pack->getKey(),
actionLabel: 'Open review pack',
url: $tenant instanceof Tenant
? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant)
: null,
freshnessAt: $pack->generated_at,
),
],
);
}
private function auditHistorySection(Collection $auditLogs): array
{
if ($auditLogs->isEmpty()) {
return $this->section(
key: 'audit_history',
label: 'Audit history',
availability: 'missing',
summary: 'No audit references were found for this support context.',
references: [
$this->missingReference('audit_log', 'Audit event not yet observed', 'Inspect event'),
],
redactionMarkers: [
[
'path' => 'audit_logs.metadata.raw',
'reason' => 'restricted_log_excerpt',
'replacement_text' => '[REDACTED]',
],
],
);
}
return $this->section(
key: 'audit_history',
label: 'Audit history',
availability: 'redacted',
summary: sprintf('%d audit reference(s) are included with redacted metadata only.', $auditLogs->count()),
freshnessNote: $this->freshnessNote($auditLogs->max('recorded_at'), 'Latest audit event'),
references: $auditLogs
->map(fn (AuditLog $auditLog): array => $this->modelReference(
type: 'audit_log',
record: $auditLog,
label: $auditLog->summaryText(),
actionLabel: 'Inspect event',
url: route('admin.monitoring.audit-log', ['event' => (int) $auditLog->getKey()]),
freshnessAt: $auditLog->recorded_at,
))
->values()
->all(),
redactionMarkers: [
[
'path' => 'audit_logs.metadata.raw',
'reason' => 'restricted_log_excerpt',
'replacement_text' => '[REDACTED]',
],
],
);
}
/**
* @param list<array<string, mixed>> $references
* @param list<array{path: ?string, reason: string, replacement_text: string}> $redactionMarkers
* @return array<string, mixed>
*/
private function section(
string $key,
string $label,
string $availability,
string $summary,
?string $freshnessNote = null,
array $references = [],
array $redactionMarkers = [],
): array {
return [
'key' => $key,
'label' => $label,
'availability' => $availability,
'summary' => $summary,
'freshness_note' => $freshnessNote,
'references' => $this->sortReferences($references),
'redaction_markers' => $redactionMarkers,
];
}
/**
* @param list<array<string, mixed>> $sections
* @return list<array<string, mixed>>
*/
private function sortSections(array $sections): array
{
$order = array_flip(self::SECTION_ORDER);
usort($sections, static fn (array $left, array $right): int => ($order[$left['key']] ?? 999) <=> ($order[$right['key']] ?? 999));
return array_values($sections);
}
/**
* @param list<array<string, mixed>> $references
* @return list<array<string, mixed>>
*/
private function sortReferences(array $references): array
{
usort($references, function (array $left, array $right): int {
$leftAvailability = $left['availability'] === 'available' ? 0 : 1;
$rightAvailability = $right['availability'] === 'available' ? 0 : 1;
if ($leftAvailability !== $rightAvailability) {
return $leftAvailability <=> $rightAvailability;
}
return [
(string) ($left['type'] ?? ''),
(string) ($left['record_id'] ?? ''),
(string) ($left['label'] ?? ''),
] <=> [
(string) ($right['type'] ?? ''),
(string) ($right['record_id'] ?? ''),
(string) ($right['label'] ?? ''),
];
});
return array_values($references);
}
private function tenantReference(Tenant $tenant): array
{
return [
'type' => 'tenant',
'record_id' => (string) $tenant->getKey(),
'label' => $tenant->name,
'action_label' => 'Open tenant',
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
'availability' => 'available',
'freshness_note' => null,
'access_reason' => null,
];
}
private function operationReference(OperationRun $run, ?Tenant $tenant): array
{
return $this->modelReference(
type: 'operation_run',
record: $run,
label: OperationRunLinks::identifier($run),
actionLabel: OperationRunLinks::openLabel(),
url: OperationRunLinks::tenantlessView($run),
freshnessAt: $run->completed_at ?? $run->updated_at,
);
}
/**
* @return list<array<string, mixed>>
*/
private function operationRelatedReferences(OperationRun $run, ?Tenant $tenant): array
{
if (! $tenant instanceof Tenant) {
return [];
}
return collect($this->relatedNavigationResolver->operationLinks($run, $tenant))
->reject(static fn (string $url, string $label): bool => $label === OperationRunLinks::collectionLabel())
->map(fn (string $url, string $label): array => [
'type' => 'operation_run',
'record_id' => (string) $run->getKey(),
'label' => $label,
'action_label' => $label,
'url' => $url,
'availability' => 'available',
'freshness_note' => null,
'access_reason' => null,
])
->values()
->all();
}
private function modelReference(
string $type,
Model $record,
string $label,
string $actionLabel,
?string $url,
mixed $freshnessAt = null,
): array {
return [
'type' => $type,
'record_id' => (string) $record->getKey(),
'label' => $label,
'action_label' => $actionLabel,
'url' => $url,
'availability' => $url === null && $type !== 'stored_report' ? 'inaccessible' : 'available',
'freshness_note' => $this->freshnessNote($freshnessAt),
'access_reason' => $url === null && $type !== 'stored_report' ? 'Canonical destination is not available from this context.' : null,
];
}
private function missingReference(string $type, string $label, string $actionLabel): array
{
return [
'type' => $type,
'record_id' => null,
'label' => $label,
'action_label' => $actionLabel,
'url' => null,
'availability' => 'missing',
'freshness_note' => null,
'access_reason' => 'No authorized record is available for this support context.',
];
}
private function freshnessNote(mixed $value, string $prefix = 'Observed'): ?string
{
if ($value instanceof CarbonInterface) {
return $prefix.': '.$value->toIso8601String();
}
if (is_string($value) && trim($value) !== '') {
return $prefix.': '.trim($value);
}
return null;
}
/**
* @param list<array<string, mixed>> $sections
*/
private function freshnessState(array $sections): string
{
$availableCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'available'));
$missingCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'missing'));
$staleCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'stale'));
$redactedCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'redacted'));
if ($availableCount === 0 && $redactedCount === 0) {
return 'missing_context';
}
if ($missingCount > 0 || $staleCount > 0) {
return 'mixed';
}
return 'fresh';
}
/**
* @param list<array<string, mixed>> $sections
*/
private function completenessNote(array $sections): ?string
{
$missing = collect($sections)
->filter(static fn (array $section): bool => $section['availability'] === 'missing')
->pluck('label')
->values()
->all();
if ($missing === []) {
return null;
}
return 'Missing context: '.implode(', ', $missing).'.';
}
}

View File

@ -1,304 +0,0 @@
<?php
declare(strict_types=1);
return [
'controls' => [
[
'control_key' => 'strong_authentication',
'name' => 'Strong authentication',
'domain_key' => 'identity_access',
'subdomain_key' => 'authentication_assurance',
'control_class' => 'preventive',
'summary' => 'Accounts and privileged actions require strong authentication before access is granted.',
'operator_description' => 'Use this control when the governance objective is proving that access depends on multi-factor or similarly strong authentication.',
'detectability_class' => 'indirect_technical',
'evaluation_strategy' => 'signal_inferred',
'evidence_archetypes' => [
'configuration_snapshot',
'policy_or_assignment_summary',
'execution_result',
],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'conditional_access_policy',
'workload' => 'entra',
'signal_keys' => [
'conditional_access.require_mfa',
'conditional_access.authentication_strength',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Microsoft conditional access is provider-owned evidence for strong authentication, not the canonical control identity.',
],
[
'subject_family_key' => 'permission_posture',
'workload' => 'entra',
'signal_keys' => [
'permission_posture.required_graph_permission',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => false,
'notes' => 'Permission posture can support authentication governance when missing permissions block assessment evidence.',
],
],
],
[
'control_key' => 'conditional_access_enforcement',
'name' => 'Conditional access enforcement',
'domain_key' => 'identity_access',
'subdomain_key' => 'access_policy',
'control_class' => 'preventive',
'summary' => 'Access decisions are governed by explicit policy conditions and assignment boundaries.',
'operator_description' => 'Use this control when evaluating whether access is constrained by conditional policies rather than unmanaged default access.',
'detectability_class' => 'direct_technical',
'evaluation_strategy' => 'state_evaluated',
'evidence_archetypes' => [
'configuration_snapshot',
'policy_or_assignment_summary',
],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'conditional_access_policy',
'workload' => 'entra',
'signal_keys' => [
'conditional_access.policy_state',
'conditional_access.assignment_scope',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Policy state and assignments are Microsoft-owned signals for the provider-neutral access enforcement objective.',
],
],
],
[
'control_key' => 'privileged_access_governance',
'name' => 'Privileged access governance',
'domain_key' => 'identity_access',
'subdomain_key' => 'privileged_access',
'control_class' => 'preventive',
'summary' => 'Privileged roles are assigned intentionally, reviewed, and limited to accountable identities.',
'operator_description' => 'Use this control when privileged role exposure, ownership, and reviewability are the core governance objective.',
'detectability_class' => 'indirect_technical',
'evaluation_strategy' => 'signal_inferred',
'evidence_archetypes' => [
'policy_or_assignment_summary',
'execution_result',
'operator_attestation',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'entra_admin_roles',
'workload' => 'entra',
'signal_keys' => [
'entra_admin_roles.global_admin_assignment',
'entra_admin_roles.privileged_role_assignment',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Directory role assignment data supports privileged access governance without becoming the control taxonomy.',
],
],
],
[
'control_key' => 'external_sharing_boundaries',
'name' => 'External sharing boundaries',
'domain_key' => 'collaboration_boundary',
'subdomain_key' => 'external_access',
'control_class' => 'preventive',
'summary' => 'External access and sharing are constrained by explicit tenant or workload boundaries.',
'operator_description' => 'Use this control when the product needs to explain whether cross-boundary collaboration is intentionally limited.',
'detectability_class' => 'workflow_attested',
'evaluation_strategy' => 'workflow_confirmed',
'evidence_archetypes' => [
'configuration_snapshot',
'operator_attestation',
'external_artifact_reference',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => false,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'sharing_boundary',
'workload' => 'microsoft_365',
'signal_keys' => [
'sharing.external_boundary_attested',
],
'supported_contexts' => ['evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Current release coverage depends on attested configuration evidence rather than direct universal evaluation.',
],
],
],
[
'control_key' => 'endpoint_hardening_compliance',
'name' => 'Endpoint hardening and compliance',
'domain_key' => 'endpoint_security',
'subdomain_key' => 'device_posture',
'control_class' => 'detective',
'summary' => 'Endpoint configuration and compliance policies express the expected device hardening posture.',
'operator_description' => 'Use this control when a finding or review references device configuration, compliance, or hardening drift.',
'detectability_class' => 'direct_technical',
'evaluation_strategy' => 'state_evaluated',
'evidence_archetypes' => [
'configuration_snapshot',
'policy_or_assignment_summary',
'execution_result',
],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'deviceConfiguration',
'workload' => 'intune',
'signal_keys' => [
'intune.device_configuration_drift',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Intune device configuration drift is a provider signal for the endpoint hardening control.',
],
[
'subject_family_key' => 'deviceCompliancePolicy',
'workload' => 'intune',
'signal_keys' => [
'intune.device_compliance_policy',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Device compliance policy data supports the same endpoint hardening objective.',
],
[
'subject_family_key' => 'drift',
'workload' => 'intune',
'signal_keys' => [
'finding.drift',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Legacy drift findings without a policy-family discriminator resolve to the broad endpoint hardening objective.',
],
],
],
[
'control_key' => 'audit_log_retention',
'name' => 'Audit log retention',
'domain_key' => 'auditability',
'subdomain_key' => 'retention',
'control_class' => 'detective',
'summary' => 'Administrative and security-relevant activity remains available for investigation for the required retention period.',
'operator_description' => 'Use this control when evidence depends on retained logs or exported audit artifacts rather than live configuration alone.',
'detectability_class' => 'external_evidence_only',
'evaluation_strategy' => 'externally_attested',
'evidence_archetypes' => [
'external_artifact_reference',
'operator_attestation',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => false,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'audit_log_retention',
'workload' => 'microsoft_365',
'signal_keys' => [
'audit.retention_attested',
],
'supported_contexts' => ['evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Current evidence is external or attested until a later slice adds direct provider evaluation.',
],
],
],
[
'control_key' => 'delegated_admin_boundaries',
'name' => 'Delegated admin boundaries',
'domain_key' => 'identity_access',
'subdomain_key' => 'delegated_administration',
'control_class' => 'preventive',
'summary' => 'Delegated administration is constrained by explicit role, tenant, and scope boundaries.',
'operator_description' => 'Use this control when evaluating whether delegated administrative access is bounded and reviewable.',
'detectability_class' => 'workflow_attested',
'evaluation_strategy' => 'workflow_confirmed',
'evidence_archetypes' => [
'policy_or_assignment_summary',
'operator_attestation',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'delegated_admin_relationship',
'workload' => 'microsoft_365',
'signal_keys' => [
'delegated_admin.relationship_boundary',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Delegated admin relationship metadata remains provider-owned and secondary to the platform control.',
],
],
],
],
];

View File

@ -1,115 +0,0 @@
<?php
use App\Support\Providers\Boundary\ProviderBoundaryOwner;
use App\Support\Providers\Boundary\ProviderBoundarySeam;
return [
'seams' => [
'provider.gateway_runtime' => [
'owner' => ProviderBoundaryOwner::ProviderOwned->value,
'description' => 'Provider-owned runtime boundary that translates provider connection identity into Microsoft Graph request options and executes Graph calls.',
'implementation_paths' => [
'app/Services/Providers/ProviderGateway.php',
'app/Services/Providers/MicrosoftGraphOptionsResolver.php',
],
'neutral_terms' => [
'provider',
'provider connection',
'target scope',
'runtime request context',
],
'retained_provider_semantics' => [
'Microsoft Graph option keys',
'client_request_id',
'tenant',
'client_id',
'client_secret',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.identity_resolution' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core identity resolution contract that resolves provider connection identity without owning provider transport option shaping.',
'implementation_paths' => [
'app/Services/Providers/ProviderIdentityResolution.php',
'app/Services/Providers/ProviderIdentityResolver.php',
'app/Services/Providers/PlatformProviderIdentityResolver.php',
],
'neutral_terms' => [
'provider connection',
'target scope',
'credential source',
'effective client identity',
],
'retained_provider_semantics' => [
'entra_tenant_id',
'platform_config',
'graph.tenant_id',
'admin.consent.callback',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
],
'provider.connection_resolution' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core provider connection selection and validation path that keeps current Microsoft connection details as bounded exception metadata.',
'implementation_paths' => [
'app/Services/Providers/ProviderConnectionResolver.php',
'app/Services/Providers/ProviderConnectionResolution.php',
],
'neutral_terms' => [
'provider',
'provider connection',
'tenant scope',
'default binding',
'unsupported combination',
],
'retained_provider_semantics' => [
'microsoft',
'entra_tenant_id',
'consent_status',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
],
'provider.operation_registry' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core operation definition catalog with provider binding metadata kept explicit and secondary.',
'implementation_paths' => [
'app/Services/Providers/ProviderOperationRegistry.php',
],
'neutral_terms' => [
'operation type',
'operation module',
'required capability',
'provider binding',
'unsupported binding',
],
'retained_provider_semantics' => [
'microsoft',
'active provider binding',
'binding_status',
'handler_notes',
'exception_notes',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.operation_start_gate' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records current Microsoft target-scope exceptions.',
'implementation_paths' => [
'app/Services/Providers/ProviderOperationStartGate.php',
],
'neutral_terms' => [
'operation',
'provider binding',
'target scope',
'execution authority',
'required capability',
],
'retained_provider_semantics' => [
'microsoft',
'target_scope.entra_tenant_id',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
],
],
];

View File

@ -20,7 +20,7 @@
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5), 'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
], ],
'covered_types' => [ 'covered_types' => [
'baseline.capture' => [ 'baseline_capture' => [
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class, 'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
'queued_stale_after_seconds' => 600, 'queued_stale_after_seconds' => 600,
'running_stale_after_seconds' => 1800, 'running_stale_after_seconds' => 1800,
@ -28,7 +28,7 @@
'direct_failed_bridge' => true, 'direct_failed_bridge' => true,
'scheduled_reconciliation' => true, 'scheduled_reconciliation' => true,
], ],
'baseline.compare' => [ 'baseline_compare' => [
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class, 'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
'queued_stale_after_seconds' => 600, 'queued_stale_after_seconds' => 600,
'running_stale_after_seconds' => 1800, 'running_stale_after_seconds' => 1800,
@ -36,7 +36,7 @@
'direct_failed_bridge' => true, 'direct_failed_bridge' => true,
'scheduled_reconciliation' => true, 'scheduled_reconciliation' => true,
], ],
'inventory.sync' => [ 'inventory_sync' => [
'job_class' => \App\Jobs\RunInventorySyncJob::class, 'job_class' => \App\Jobs\RunInventorySyncJob::class,
'queued_stale_after_seconds' => 300, 'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 1200, 'running_stale_after_seconds' => 1200,
@ -52,7 +52,15 @@
'direct_failed_bridge' => true, 'direct_failed_bridge' => true,
'scheduled_reconciliation' => 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, 'job_class' => \App\Jobs\EntraGroupSyncJob::class,
'queued_stale_after_seconds' => 300, 'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900, 'running_stale_after_seconds' => 900,
@ -60,7 +68,7 @@
'direct_failed_bridge' => false, 'direct_failed_bridge' => false,
'scheduled_reconciliation' => true, 'scheduled_reconciliation' => true,
], ],
'directory.role_definitions.sync' => [ 'directory_role_definitions.sync' => [
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class, 'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
'queued_stale_after_seconds' => 300, 'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900, 'running_stale_after_seconds' => 900,
@ -76,7 +84,7 @@
'direct_failed_bridge' => false, 'direct_failed_bridge' => false,
'scheduled_reconciliation' => true, 'scheduled_reconciliation' => true,
], ],
'backup.schedule.execute' => [ 'backup_schedule_run' => [
'job_class' => \App\Jobs\RunBackupScheduleJob::class, 'job_class' => \App\Jobs\RunBackupScheduleJob::class,
'queued_stale_after_seconds' => 300, 'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 1200, 'running_stale_after_seconds' => 1200,

View File

@ -1,162 +0,0 @@
@php
use Illuminate\Support\Str;
/** @var array<string, mixed> $bundle */
$summary = is_array($bundle['summary'] ?? null) ? $bundle['summary'] : [];
$context = is_array($bundle['context'] ?? null) ? $bundle['context'] : [];
$sections = is_array($bundle['sections'] ?? null) ? $bundle['sections'] : [];
$redaction = is_array($bundle['redaction'] ?? null) ? $bundle['redaction'] : [];
$notes = is_array($bundle['notes'] ?? null) ? $bundle['notes'] : [];
$availabilityColor = static function (?string $availability): string {
return match ($availability) {
'available', 'current', 'fresh', 'ready' => 'success',
'partial', 'stale' => 'warning',
'error', 'missing', 'unavailable' => 'danger',
default => 'gray',
};
};
$referenceDescription = static function (array $reference): string {
$parts = [
is_string($reference['type'] ?? null) && trim((string) $reference['type']) !== ''
? (string) $reference['type']
: 'reference',
is_string($reference['availability'] ?? null) && trim((string) $reference['availability']) !== ''
? (string) $reference['availability']
: 'missing',
];
if (is_string($reference['freshness_note'] ?? null) && trim((string) $reference['freshness_note']) !== '') {
$parts[] = (string) $reference['freshness_note'];
}
return implode(' - ', $parts);
};
@endphp
<div class="space-y-4">
<x-filament::section
:heading="data_get($summary, 'headline', data_get($bundle, 'headline', 'Support diagnostics'))"
:description="data_get($summary, 'dominant_issue', data_get($bundle, 'dominant_issue', 'No dominant issue available.'))"
>
<x-slot name="afterHeader">
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ data_get($context, 'type', data_get($bundle, 'context_type', 'tenant')) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
{{ data_get($summary, 'freshness_state', data_get($bundle, 'freshness_state', 'mixed')) }}
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ str_replace('_', '-', (string) data_get($redaction, 'mode', data_get($bundle, 'redaction_mode', 'default-redacted'))) }}
</x-filament::badge>
</div>
</x-slot>
<dl class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div class="space-y-1">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Workspace</dt>
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'workspace_label', 'Workspace unavailable') }}</dd>
</div>
<div class="space-y-1">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant</dt>
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'tenant_label', 'Tenant unavailable') }}</dd>
</div>
</dl>
</x-filament::section>
@if ($notes !== [])
<x-filament::section
heading="Support notes"
description="The bundle stays read-only and redacted even when the source records include provider-only details."
compact
>
<div class="space-y-2">
@foreach ($notes as $note)
@if (is_string($note) && trim($note) !== '')
<div class="flex items-start gap-2">
<x-filament::badge color="warning" size="sm">Note</x-filament::badge>
<p class="text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $note }}</p>
</div>
@endif
@endforeach
</div>
</x-filament::section>
@endif
<div class="space-y-3">
@foreach ($sections as $section)
@php
$references = is_array($section['references'] ?? null) ? $section['references'] : [];
$markers = is_array($section['redaction_markers'] ?? null) ? $section['redaction_markers'] : [];
$availability = is_string($section['availability'] ?? null) && trim((string) $section['availability']) !== ''
? (string) $section['availability']
: 'missing';
$sectionLabel = $section['label'] ?? $section['key'] ?? 'Section';
$sectionSummary = $section['summary'] ?? 'No summary available.';
@endphp
<x-filament::section
:heading="$sectionLabel"
:description="$sectionSummary"
compact
>
<x-slot name="afterHeader">
<x-filament::badge :color="$availabilityColor($availability)" size="sm">
{{ $availability }}
</x-filament::badge>
</x-slot>
<div class="space-y-3">
@if (is_string($section['freshness_note'] ?? null) && trim((string) $section['freshness_note']) !== '')
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $section['freshness_note'] }}</p>
@endif
@if ($references !== [])
<div class="space-y-2">
@foreach ($references as $reference)
@php
$referenceLabel = $reference['label'] ?? 'Reference unavailable';
$referenceUrl = is_string($reference['url'] ?? null) && trim((string) $reference['url']) !== ''
? (string) $reference['url']
: null;
$referenceActionLabel = $reference['action_label'] ?? ($referenceUrl ? 'Open' : 'Unavailable');
@endphp
<x-filament::section
:heading="$referenceLabel"
:description="$referenceDescription($reference)"
compact
secondary
>
<x-slot name="afterHeader">
@if ($referenceUrl)
<x-filament::link :href="$referenceUrl" size="sm">
{{ $referenceActionLabel }}
</x-filament::link>
@else
<x-filament::badge color="gray" size="sm">
{{ $referenceActionLabel }}
</x-filament::badge>
@endif
</x-slot>
</x-filament::section>
@endforeach
</div>
@endif
@if ($markers !== [])
<div class="flex flex-wrap gap-2">
@foreach ($markers as $marker)
<x-filament::badge color="warning" size="sm">
{{ trim((string) (($marker['replacement_text'] ?? '[REDACTED]').' '.Str::of((string) ($marker['reason'] ?? 'redacted'))->replace('_', ' '))) }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
</x-filament::section>
@endforeach
</div>
</div>

View File

@ -136,9 +136,9 @@
@if (! $hasStoredPermissionData) @if (! $hasStoredPermissionData)
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200"> <div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
<div class="font-semibold">No data available</div> <div class="font-semibold">Keine Daten verfügbar</div>
<div class="mt-1"> <div class="mt-1">
No stored verification data is available for this tenant. Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor.
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>. <a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
</div> </div>
</div> </div>

View File

@ -4,8 +4,6 @@
use App\Support\Governance\PlatformVocabularyGlossary; use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\OperationCatalog; 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 { it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries()) $classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
@ -28,30 +26,3 @@
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject') ->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture'); ->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([]);
});

View File

@ -2,14 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class); 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',
]);
});

View File

@ -82,7 +82,7 @@
$retentionRun = OperationRun::query() $retentionRun = OperationRun::query()
->where('tenant_id', (int) $tenant->id) ->where('tenant_id', (int) $tenant->id)
->where('type', 'backup.schedule.retention') ->where('type', 'backup_schedule_retention')
->latest('id') ->latest('id')
->first(); ->first();

Some files were not shown because too many files have changed in this diff Show More