Compare commits
6 Commits
234-dead-t
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 58f9bb7355 | |||
| 110245a9ec | |||
| bd26e209de | |||
| 6a5b8a3a11 | |||
| 2752515da5 | |||
| 603d509b8f |
@ -1,4 +1,4 @@
|
|||||||
[mcp_servers.laravel-boost]
|
[mcp_servers.laravel-boost]
|
||||||
command = "vendor/bin/sail"
|
command = "./scripts/platform-sail"
|
||||||
args = ["artisan", "boost:mcp"]
|
args = ["artisan", "boost:mcp"]
|
||||||
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
|
cwd = "/Users/ahmeddarrazi/Documents/projects/wt-plattform"
|
||||||
|
|||||||
16
.github/agents/copilot-instructions.md
vendored
16
.github/agents/copilot-instructions.md
vendored
@ -244,6 +244,16 @@ ## Active Technologies
|
|||||||
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
|
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
|
||||||
- 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` (233-stale-run-visibility)
|
- 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` (233-stale-run-visibility)
|
||||||
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
|
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
|
||||||
|
- 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 (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)
|
||||||
|
- 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -278,9 +288,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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`
|
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||||
- 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers
|
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
||||||
- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger`
|
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
383
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
Normal file
383
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
---
|
||||||
|
name: spec-kit-next-best-one-shot
|
||||||
|
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then create Spec Kit preparation artifacts in one pass: spec.md, plan.md, and tasks.md. Use when the user wants the agent to choose the next best spec based on roadmap fit, current candidates, repository state, platform priorities, governance foundations, UX improvements, architecture cleanup, or implementation readiness. 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 create the Spec Kit preparation artifacts in one pass:
|
||||||
|
|
||||||
|
1. choose the next best spec candidate
|
||||||
|
2. create `spec.md`
|
||||||
|
3. create `plan.md`
|
||||||
|
4. create `tasks.md`
|
||||||
|
5. provide a manual analysis prompt
|
||||||
|
|
||||||
|
This skill prepares implementation work, but it must not perform implementation.
|
||||||
|
|
||||||
|
The intended workflow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
roadmap.md + spec-candidates.md
|
||||||
|
→ select next best spec
|
||||||
|
→ 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 things like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Wähle die nächste geeignete Spec und erstelle spec, plan und tasks.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Schau in roadmap.md und spec-candidates.md und mach daraus die nächste Spec.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Such die beste nächste Spec aus und bereite sie in einem Rutsch vor.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 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, create no spec and explain why.
|
||||||
|
|
||||||
|
## Required Repository Checks
|
||||||
|
|
||||||
|
Before selecting the next spec, inspect:
|
||||||
|
|
||||||
|
1. `.specify/memory/constitution.md`
|
||||||
|
2. `.specify/templates/`
|
||||||
|
3. `specs/`
|
||||||
|
4. `docs/product/spec-candidates.md`
|
||||||
|
5. roadmap documents under `docs/product/`, especially `roadmap.md` if present
|
||||||
|
6. nearby existing specs related to top candidate areas
|
||||||
|
7. current spec numbering conventions
|
||||||
|
8. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
|
||||||
|
|
||||||
|
Do not edit application code.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Candidate Selection Output
|
||||||
|
|
||||||
|
Before creating files, prepare a concise decision summary for the final response.
|
||||||
|
|
||||||
|
The selected candidate should include:
|
||||||
|
|
||||||
|
- selected candidate title
|
||||||
|
- why it was selected
|
||||||
|
- why the nearest alternatives were not selected now
|
||||||
|
- roadmap relationship
|
||||||
|
- expected implementation slice
|
||||||
|
|
||||||
|
Do not create multiple specs unless the repository convention explicitly supports it and the user asked for it.
|
||||||
|
|
||||||
|
## Selection Matrix
|
||||||
|
|
||||||
|
When comparing candidates, use a small matrix internally or in the final summary:
|
||||||
|
|
||||||
|
| Candidate | Roadmap fit | Foundation value | Scope size | Repo readiness | Risk reduction | Decision |
|
||||||
|
|---|---:|---:|---:|---:|---:|---|
|
||||||
|
|
||||||
|
Keep it concise. Do not over-analyze if the best candidate is obvious.
|
||||||
|
|
||||||
|
## 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 consistent with existing Spec Kit conventions.
|
||||||
|
|
||||||
|
Do not create implementation files.
|
||||||
|
|
||||||
|
## `spec.md` Requirements
|
||||||
|
|
||||||
|
The spec must be product- and behavior-oriented.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- Feature title
|
||||||
|
- Selected-candidate rationale
|
||||||
|
- Problem statement
|
||||||
|
- Business/product value
|
||||||
|
- Roadmap relationship
|
||||||
|
- 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
|
||||||
|
- Follow-up spec candidates if the selected candidate had to be narrowed
|
||||||
|
|
||||||
|
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
|
||||||
|
- provider/platform boundary clarity where relevant
|
||||||
|
- versioned governance semantics where relevant
|
||||||
|
|
||||||
|
## `plan.md` Requirements
|
||||||
|
|
||||||
|
The plan must be repo-aware and implementation-oriented, but 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
|
||||||
|
|
||||||
|
Where relevant, clearly distinguish:
|
||||||
|
|
||||||
|
- execution truth
|
||||||
|
- artifact truth
|
||||||
|
- backup/snapshot truth
|
||||||
|
- evidence truth
|
||||||
|
- recovery confidence
|
||||||
|
- operator next action
|
||||||
|
|
||||||
|
Use those distinctions only when relevant to the selected spec.
|
||||||
|
|
||||||
|
## `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 selected roadmap/candidate item is too broad, narrow it into the smallest valuable first implementation slice.
|
||||||
|
|
||||||
|
Add a `Follow-up spec candidates` section for deferred concerns.
|
||||||
|
|
||||||
|
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
|
||||||
|
- compliance mapping library expansion
|
||||||
|
- MSP portfolio rollups
|
||||||
|
- provider-specific adapters
|
||||||
|
|
||||||
|
Do not force follow-up candidates into the primary spec.
|
||||||
|
|
||||||
|
## Final Response Requirements
|
||||||
|
|
||||||
|
After creating or updating the artifacts, respond with:
|
||||||
|
|
||||||
|
1. Selected candidate
|
||||||
|
2. Why this candidate was selected
|
||||||
|
3. Why close alternatives were deferred
|
||||||
|
4. Created or updated spec directory
|
||||||
|
5. Files created or updated
|
||||||
|
6. Important repo-based adjustments made
|
||||||
|
7. Assumptions made
|
||||||
|
8. Open questions, if any
|
||||||
|
9. Recommended next manual analysis prompt
|
||||||
|
10. Explicit statement that no implementation was performed
|
||||||
|
|
||||||
|
Keep the 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, roadmap-konform und constitution-konform sind.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Keine Implementierung.
|
||||||
|
- Keine Codeänderungen.
|
||||||
|
- Keine Scope-Erweiterung.
|
||||||
|
- Prüfe nur gegen Repo-Wahrheit.
|
||||||
|
- Prüfe auch, ob die ausgewählte Spec wirklich die sinnvollste nächste Spec aus `docs/product/spec-candidates.md` und `docs/product/roadmap.md` war.
|
||||||
|
- 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
|
||||||
|
Nutze den Skill spec-kit-next-best-one-shot.
|
||||||
|
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und erstelle spec, plan und tasks in einem Rutsch.
|
||||||
|
Keine Implementierung.
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior:
|
||||||
|
|
||||||
|
1. Inspect constitution, templates, specs, roadmap, and spec candidates.
|
||||||
|
2. Compare candidate suitability.
|
||||||
|
3. Select the next best candidate.
|
||||||
|
4. Determine the next valid spec number.
|
||||||
|
5. Create `spec.md`, `plan.md`, and `tasks.md`.
|
||||||
|
6. Keep scope tight.
|
||||||
|
7. Do not implement.
|
||||||
|
8. Return selection rationale, artifact summary, and next manual analysis prompt.
|
||||||
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
Normal file
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@ -1,32 +1,30 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.7.0 -> 2.8.0
|
- Version change: 2.9.0 -> 2.10.0
|
||||||
- Modified principles: None
|
- Modified principles:
|
||||||
|
- Expanded Operations / Run Observability Standard so OperationRun
|
||||||
|
start UX is shared-contract-owned instead of surface-owned
|
||||||
|
- Expanded Governance review expectations for OperationRun-starting
|
||||||
|
features, explicit queued-notification policy, and bounded
|
||||||
|
exceptions
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
|
||||||
migration shims, dual-write logic, and compatibility fixtures in a
|
queued toast/link/event/message semantics, run/artifact deep links,
|
||||||
pre-production codebase; includes AI-agent verification checklist,
|
queued DB-notification policy, and tenant/workspace-safe operation
|
||||||
review rule, and explicit exit condition at first production deploy
|
URL resolution behind one shared OperationRun UX layer
|
||||||
- Shared Pattern First For Cross-Cutting Interaction Classes
|
|
||||||
(XCUT-001): requires shared contracts/presenters/builders for
|
|
||||||
notifications, status messaging, action links, dashboard signals,
|
|
||||||
navigation, and similar interaction classes before any local
|
|
||||||
domain-specific variant is allowed
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: added "Compatibility posture"
|
- .specify/templates/spec-template.md: add OperationRun UX Impact
|
||||||
default block ✅
|
section + start-contract prompts ✅
|
||||||
- .specify/templates/spec-template.md: add cross-cutting shared-pattern
|
- .specify/templates/plan-template.md: add OperationRun UX Impact
|
||||||
reuse block ✅
|
planning section + constitution checks ✅
|
||||||
- .specify/templates/plan-template.md: add shared pattern and system
|
- .specify/templates/tasks-template.md: add central start-UX reuse,
|
||||||
fit section ✅
|
queued-notification policy, and exception tasks ✅
|
||||||
- .specify/templates/tasks-template.md: add cross-cutting reuse task
|
- .specify/templates/checklist-template.md: add OperationRun start
|
||||||
requirements ✅
|
UX review checks ✅
|
||||||
- .specify/templates/checklist-template.md: add shared-pattern reuse
|
- docs/product/standards/README.md: refresh constitution index for
|
||||||
review checks ✅
|
the new ops-UX contract ✅
|
||||||
- .github/agents/copilot-instructions.md: added "Pre-production
|
|
||||||
compatibility check" agent checklist ✅
|
|
||||||
- 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
|
||||||
@ -66,6 +64,15 @@ ### No Premature Abstraction (ABSTR-001)
|
|||||||
- Test convenience alone is not sufficient justification for a new abstraction.
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
|
### First Provider Is Not Platform Core (PROV-001)
|
||||||
|
- Microsoft is the current first provider, not the platform core.
|
||||||
|
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
|
||||||
|
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
|
||||||
|
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
|
||||||
|
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
|
||||||
|
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
|
||||||
|
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
|
||||||
|
|
||||||
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
@ -302,24 +309,57 @@ ### 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; they only: authorize, create/reuse run (dedupe), enqueue work,
|
- Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only:
|
||||||
confirm + “View run”.
|
authorize, create/reuse run (dedupe), enqueue work, and hand queued/start-state feedback to the shared
|
||||||
|
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`, it MUST use exactly three feedback surfaces — no others:
|
If a feature creates/reuses `OperationRun`, its default feedback contract is exactly three surfaces.
|
||||||
|
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 0–100. Otherwise render indeterminate + elapsed time.
|
- Determinate progress MUST be clamped to 0–100. 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).
|
||||||
@ -360,6 +400,10 @@ ### 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.
|
||||||
|
|
||||||
@ -1608,6 +1652,12 @@ ### Scope, Compliance, and Review Expectations
|
|||||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
- 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 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.
|
||||||
@ -1625,4 +1675,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.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19
|
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24
|
||||||
|
|||||||
@ -40,9 +40,13 @@ 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
|
||||||
|
|||||||
@ -32,18 +32,30 @@ ## 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
|
||||||
|
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
|
||||||
|
|
||||||
## Signals, Exceptions, And Test Depth
|
## Signals, Exceptions, And Test Depth
|
||||||
|
|
||||||
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
- [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||||
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
- [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
||||||
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
- [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||||
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
- [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,29 @@ ## 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
|
||||||
|
|
||||||
|
> **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`.**
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
|
||||||
|
- **Provider-owned seams**: [List or `N/A`]
|
||||||
|
- **Platform-core seams**: [List or `N/A`]
|
||||||
|
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
|
||||||
|
- **Retained provider-specific semantics and why**: [none / short explanation]
|
||||||
|
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
@ -68,7 +91,8 @@ ## 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`
|
||||||
- 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
|
- 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, 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
|
||||||
@ -82,6 +106,7 @@ ## Constitution Check
|
|||||||
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
|
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
|
||||||
|
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
|
||||||
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
|||||||
@ -47,6 +47,26 @@ ## 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`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: [yes/no]
|
||||||
|
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
|
||||||
|
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
|
||||||
|
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
|
||||||
|
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
|
||||||
|
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
Use this section to classify UI and surface risk once. If the feature does
|
Use this section to classify UI and surface risk once. If the feature does
|
||||||
@ -234,6 +254,13 @@ ## Requirements *(mandatory)*
|
|||||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
||||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
|
||||||
|
- classify each touched seam as provider-owned or platform-core,
|
||||||
|
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
|
||||||
|
- name the neutral platform terms or shared contracts being preserved,
|
||||||
|
- explain why any retained provider-specific semantics are the narrowest current-release truth,
|
||||||
|
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
||||||
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
||||||
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
||||||
@ -246,12 +273,21 @@ ## 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 Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
- 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,
|
||||||
- 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),
|
||||||
|
|||||||
@ -18,17 +18,22 @@ # 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.
|
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.
|
||||||
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),
|
||||||
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
|
- 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,
|
||||||
|
- 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:
|
||||||
@ -51,6 +56,11 @@ # Tasks: [FEATURE NAME]
|
|||||||
- extending the shared path when it is sufficient for current-release truth,
|
- extending the shared path when it is sufficient for current-release truth,
|
||||||
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
|
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
|
||||||
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
|
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
|
||||||
|
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
|
||||||
|
- classifying each touched seam as provider-owned or platform-core,
|
||||||
|
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
|
||||||
|
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
|
||||||
|
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
|
||||||
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
||||||
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
||||||
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776593337482,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776593337489,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776593337495,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776593337500,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}
|
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776976148151,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776976148156,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776976148162,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776976148168,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}
|
||||||
@ -1 +1 @@
|
|||||||
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776593336106,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776593336125,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776593336132,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776593336138,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776593336144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}
|
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776976148127,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776976148139,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776976148143,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776976148144,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776976148144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}
|
||||||
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
|||||||
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776593335180,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776593335194,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776593335198,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776593335206,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776593335213,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776593335219,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776593335230,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776593335236,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776593335243,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776593335251,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776593335258,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776593335264,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776593335271,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776593335278,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}
|
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776976148162,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776976148164,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776976148166,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776976148173,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776976148180,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776976148185,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776976148187,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776976148192,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776976148195,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776976148199,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776976148200,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776976148210,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776976148214,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776976148225,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}
|
||||||
File diff suppressed because one or more lines are too long
@ -67,7 +67,6 @@ public function handle(): int
|
|||||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||||
'tenant_id' => $tenantRouteKey,
|
'tenant_id' => $tenantRouteKey,
|
||||||
'app_certificate_thumbprint' => null,
|
'app_certificate_thumbprint' => null,
|
||||||
'app_status' => 'ok',
|
|
||||||
'app_notes' => null,
|
'app_notes' => null,
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
'status' => Tenant::STATUS_ACTIVE,
|
||||||
'environment' => 'dev',
|
'environment' => 'dev',
|
||||||
|
|||||||
@ -51,6 +51,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\ProviderConnectionSurfaceSummary;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
@ -317,7 +320,7 @@ public function content(Schema $schema): Schema
|
|||||||
Section::make('Tenant')
|
Section::make('Tenant')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra Tenant ID (GUID)')
|
->label('Tenant ID (GUID)')
|
||||||
->required()
|
->required()
|
||||||
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
||||||
->rules(['uuid'])
|
->rules(['uuid'])
|
||||||
@ -423,7 +426,8 @@ public function content(Schema $schema): Schema
|
|||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Directory (tenant) ID')
|
->label('Target scope ID')
|
||||||
|
->helperText('Provider-owned Microsoft tenant detail for this selected target scope.')
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false),
|
->dehydrated(false),
|
||||||
Toggle::make('uses_dedicated_override')
|
Toggle::make('uses_dedicated_override')
|
||||||
@ -461,6 +465,13 @@ public function content(Schema $schema): Schema
|
|||||||
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
||||||
|
TextInput::make('new_connection.target_scope_id')
|
||||||
|
->label('Target scope ID')
|
||||||
|
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false)
|
||||||
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
||||||
|
->helperText('The provider connection will point to this tenant target scope.'),
|
||||||
TextInput::make('new_connection.connection_type')
|
TextInput::make('new_connection.connection_type')
|
||||||
->label('Connection type')
|
->label('Connection type')
|
||||||
->default('Platform connection')
|
->default('Platform connection')
|
||||||
@ -598,7 +609,9 @@ public function content(Schema $schema): Schema
|
|||||||
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
||||||
? null
|
? null
|
||||||
: 'You do not have permission to start bootstrap actions.')
|
: 'You do not have permission to start bootstrap actions.')
|
||||||
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))),
|
->action(fn (Get $get) => $this->startBootstrap(
|
||||||
|
$this->normalizeBootstrapOperationTypes((array) ($get('bootstrap_operation_types') ?? [])),
|
||||||
|
)),
|
||||||
]),
|
]),
|
||||||
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
||||||
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
||||||
@ -606,9 +619,11 @@ public function content(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->afterValidation(function (): void {
|
->afterValidation(function (): void {
|
||||||
$types = $this->data['bootstrap_operation_types'] ?? [];
|
$types = $this->data['bootstrap_operation_types'] ?? [];
|
||||||
$this->selectedBootstrapOperationTypes = is_array($types)
|
$this->selectedBootstrapOperationTypes = $this->normalizeBootstrapOperationTypes(
|
||||||
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''))
|
is_array($types) ? $types : [],
|
||||||
: [];
|
);
|
||||||
|
|
||||||
|
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
|
||||||
|
|
||||||
$this->touchOnboardingSessionStep('bootstrap');
|
$this->touchOnboardingSessionStep('bootstrap');
|
||||||
}),
|
}),
|
||||||
@ -642,6 +657,10 @@ public function content(Schema $schema): Schema
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||||
]),
|
]),
|
||||||
|
Callout::make('Bootstrap needs attention')
|
||||||
|
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
||||||
|
->warning()
|
||||||
|
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
|
||||||
Callout::make('After completion')
|
Callout::make('After completion')
|
||||||
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
||||||
->info()
|
->info()
|
||||||
@ -649,7 +668,7 @@ public function content(Schema $schema): Schema
|
|||||||
UnorderedList::make([
|
UnorderedList::make([
|
||||||
'Tenant status will be set to Active.',
|
'Tenant status will be set to Active.',
|
||||||
'Backup, inventory, and compliance operations become available.',
|
'Backup, inventory, and compliance operations become available.',
|
||||||
'The provider connection will be used for all Graph API calls.',
|
'The provider connection will be used for provider API calls.',
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
Toggle::make('override_blocked')
|
Toggle::make('override_blocked')
|
||||||
@ -733,10 +752,111 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
|
|||||||
|
|
||||||
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
||||||
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
||||||
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
|
? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, mixed> $operationTypes
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
||||||
|
{
|
||||||
|
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($operationTypes as $key => $value) {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalizedValue = trim($value);
|
||||||
|
|
||||||
|
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
||||||
|
$normalized[] = $normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($key) || trim($key) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isSelected = match (true) {
|
||||||
|
is_bool($value) => $value,
|
||||||
|
is_int($value) => $value === 1,
|
||||||
|
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
$normalizedKey = trim($key);
|
||||||
|
|
||||||
|
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
||||||
|
$normalized[] = $normalizedKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function supportedBootstrapCapabilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||||
|
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $operationTypes
|
||||||
|
*/
|
||||||
|
private function persistBootstrapSelection(array $operationTypes): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
|
||||||
|
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
||||||
|
$existing = is_array($existing)
|
||||||
|
? $this->normalizeBootstrapOperationTypes($existing)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if ($normalized === $existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->setOnboardingSession($this->mutationService()->mutate(
|
||||||
|
draft: $this->onboardingSession,
|
||||||
|
actor: $user,
|
||||||
|
expectedVersion: $this->expectedDraftVersion(),
|
||||||
|
incrementVersion: false,
|
||||||
|
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
|
||||||
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
|
$state['bootstrap_operation_types'] = $normalized;
|
||||||
|
|
||||||
|
$draft->state = $state;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
} catch (OnboardingDraftConflictException) {
|
||||||
|
$this->handleDraftConflict();
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (OnboardingDraftImmutableException) {
|
||||||
|
$this->handleImmutableDraft();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, TenantOnboardingSession>
|
* @return Collection<int, TenantOnboardingSession>
|
||||||
*/
|
*/
|
||||||
@ -1464,6 +1584,7 @@ private function initializeWizardData(): void
|
|||||||
// Ensure all entangled schema state paths exist at render time.
|
// Ensure all entangled schema state paths exist at render time.
|
||||||
// Livewire v4 can throw when entangling to missing nested array keys.
|
// Livewire v4 can throw when entangling to missing nested array keys.
|
||||||
$this->data['notes'] ??= '';
|
$this->data['notes'] ??= '';
|
||||||
|
$this->data['bootstrap_operation_types'] ??= [];
|
||||||
$this->data['override_blocked'] ??= false;
|
$this->data['override_blocked'] ??= false;
|
||||||
$this->data['override_reason'] ??= '';
|
$this->data['override_reason'] ??= '';
|
||||||
$this->data['new_connection'] ??= [];
|
$this->data['new_connection'] ??= [];
|
||||||
@ -1483,6 +1604,7 @@ private function initializeWizardData(): void
|
|||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
||||||
|
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id;
|
||||||
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
||||||
$this->data['name'] ??= (string) $tenant->name;
|
$this->data['name'] ??= (string) $tenant->name;
|
||||||
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
||||||
@ -1534,7 +1656,7 @@ private function initializeWizardData(): void
|
|||||||
|
|
||||||
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
||||||
if (is_array($types)) {
|
if (is_array($types)) {
|
||||||
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
$this->data['bootstrap_operation_types'] = $this->normalizeBootstrapOperationTypes($types);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1566,14 +1688,56 @@ private function providerConnectionOptions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnection::query()
|
return ProviderConnection::query()
|
||||||
|
->with('tenant')
|
||||||
->where('workspace_id', (int) $this->workspace->getKey())
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->orderByDesc('is_default')
|
->orderByDesc('is_default')
|
||||||
->orderBy('display_name')
|
->orderBy('display_name')
|
||||||
->pluck('display_name', 'id')
|
->get()
|
||||||
|
->mapWithKeys(fn (ProviderConnection $connection): array => [
|
||||||
|
(int) $connection->getKey() => sprintf(
|
||||||
|
'%s — %s',
|
||||||
|
(string) $connection->display_name,
|
||||||
|
$this->providerConnectionTargetScopeSummary($connection),
|
||||||
|
),
|
||||||
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return 'Target scope needs review';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return array_merge([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'target_scope' => [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
'scope_identifier' => (string) $connection->entra_tenant_id,
|
||||||
|
'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id),
|
||||||
|
'shared_label' => 'Target scope',
|
||||||
|
'shared_help_text' => 'The platform scope this provider connection represents.',
|
||||||
|
],
|
||||||
|
'provider_identity_context' => [],
|
||||||
|
], $extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function verificationStatusLabel(): string
|
private function verificationStatusLabel(): string
|
||||||
{
|
{
|
||||||
return BadgeCatalog::spec(
|
return BadgeCatalog::spec(
|
||||||
@ -2489,12 +2653,11 @@ public function selectProviderConnection(int $providerConnectionId): void
|
|||||||
workspace: $this->workspace,
|
workspace: $this->workspace,
|
||||||
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'tenant_db_id' => (int) $tenant->getKey(),
|
'tenant_db_id' => (int) $tenant->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actor: $user,
|
actor: $user,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@ -2547,6 +2710,22 @@ public function createProviderConnection(array $data): void
|
|||||||
abort(422);
|
abort(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
||||||
|
provider: 'microsoft',
|
||||||
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
||||||
|
scopeIdentifier: (string) $tenant->tenant_id,
|
||||||
|
scopeDisplayName: $displayName,
|
||||||
|
providerSpecificIdentity: [
|
||||||
|
'microsoft_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'new_connection.target_scope_id' => $targetScope['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($usesDedicatedCredential) {
|
if ($usesDedicatedCredential) {
|
||||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
||||||
}
|
}
|
||||||
@ -2623,14 +2802,11 @@ public function createProviderConnection(array $data): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.created',
|
action: 'provider_connection.created',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'connection_type' => $connection->connection_type->value,
|
'connection_type' => $connection->connection_type->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -2646,15 +2822,12 @@ public function createProviderConnection(array $data): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.connection_type_changed',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'from_connection_type' => $previousConnectionType->value,
|
'from_connection_type' => $previousConnectionType->value,
|
||||||
'to_connection_type' => $connection->connection_type->value,
|
'to_connection_type' => $connection->connection_type->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.create',
|
'source' => 'managed_tenant_onboarding_wizard.create',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -2966,7 +3139,7 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$registry = app(ProviderOperationRegistry::class);
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== '')));
|
$types = $this->normalizeBootstrapOperationTypes($operationTypes);
|
||||||
|
|
||||||
$types = array_values(array_filter(
|
$types = array_values(array_filter(
|
||||||
$types,
|
$types,
|
||||||
@ -3236,18 +3409,18 @@ private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, str
|
|||||||
|
|
||||||
private function resolveBootstrapCapability(string $operationType): ?string
|
private function resolveBootstrapCapability(string $operationType): ?string
|
||||||
{
|
{
|
||||||
return match ($operationType) {
|
return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
|
||||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
|
||||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canStartAnyBootstrap(): bool
|
private function canStartAnyBootstrap(): bool
|
||||||
{
|
{
|
||||||
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC)
|
foreach ($this->supportedBootstrapCapabilities() as $capability) {
|
||||||
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC)
|
if ($this->currentUserCan($capability)) {
|
||||||
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP);
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function currentUserCan(string $capability): bool
|
private function currentUserCan(string $capability): bool
|
||||||
@ -3498,33 +3671,59 @@ private function completionSummaryVerificationDetail(): string
|
|||||||
private function completionSummaryBootstrapLabel(): string
|
private function completionSummaryBootstrapLabel(): string
|
||||||
{
|
{
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
return 'Skipped';
|
return $this->completionSummarySelectedBootstrapTypes() === []
|
||||||
|
? 'Skipped'
|
||||||
|
: 'Selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
|
||||||
|
return 'Action required';
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||||
$runs = is_array($runs) ? $runs : [];
|
$runs = is_array($runs) ? $runs : [];
|
||||||
|
|
||||||
if ($runs === []) {
|
if ($runs !== []) {
|
||||||
return 'Skipped';
|
return 'Started';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Started';
|
return $this->completionSummarySelectedBootstrapTypes() === []
|
||||||
|
? 'Skipped'
|
||||||
|
: 'Selected';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function completionSummaryBootstrapDetail(): string
|
private function completionSummaryBootstrapDetail(): string
|
||||||
{
|
{
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
return 'No bootstrap actions selected';
|
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
||||||
|
|
||||||
|
return $selectedTypes === []
|
||||||
|
? 'No bootstrap actions selected'
|
||||||
|
: sprintf('%d action(s) selected', count($selectedTypes));
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||||
$runs = is_array($runs) ? $runs : [];
|
$runs = is_array($runs) ? $runs : [];
|
||||||
|
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
||||||
|
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
|
||||||
|
|
||||||
if ($runs === []) {
|
if ($selectedTypes === []) {
|
||||||
return 'No bootstrap actions selected';
|
return 'No bootstrap actions selected';
|
||||||
}
|
}
|
||||||
|
|
||||||
return sprintf('%d operation(s) started', count($runs));
|
if ($actionRequiredDetail !== null) {
|
||||||
|
return $actionRequiredDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($runs === []) {
|
||||||
|
return sprintf('%d action(s) selected', count($selectedTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($runs) < count($selectedTypes)) {
|
||||||
|
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%d action(s) started', count($runs));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function completionSummaryBootstrapSummary(): string
|
private function completionSummaryBootstrapSummary(): string
|
||||||
@ -3536,11 +3735,130 @@ private function completionSummaryBootstrapSummary(): string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function showCompletionSummaryBootstrapRecovery(): bool
|
||||||
|
{
|
||||||
|
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryBootstrapRecoveryMessage(): string
|
||||||
|
{
|
||||||
|
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
|
||||||
|
}
|
||||||
|
|
||||||
private function completionSummaryBootstrapColor(): string
|
private function completionSummaryBootstrapColor(): string
|
||||||
{
|
{
|
||||||
return $this->completionSummaryBootstrapLabel() === 'Started'
|
return match ($this->completionSummaryBootstrapLabel()) {
|
||||||
? 'info'
|
'Action required' => 'warning',
|
||||||
: 'gray';
|
'Started' => 'info',
|
||||||
|
'Selected' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryBootstrapActionRequiredDetail(): ?string
|
||||||
|
{
|
||||||
|
$reasonCode = $this->completionSummaryBootstrapReasonCode();
|
||||||
|
|
||||||
|
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $this->completionSummaryBootstrapFailedRun();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return $reasonCode === 'bootstrap_partial_failure'
|
||||||
|
? 'A bootstrap action needs attention'
|
||||||
|
: 'A bootstrap action failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
$operatorLabel = data_get($context, 'reason_translation.operator_label');
|
||||||
|
|
||||||
|
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
|
||||||
|
return trim($operatorLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($run->outcome) {
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
|
||||||
|
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
|
||||||
|
default => 'A bootstrap action failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryBootstrapReasonCode(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reasonCode) ? $reasonCode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completionSummaryBootstrapFailedRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
return once(function (): ?OperationRun {
|
||||||
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($runMap)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runIds = array_values(array_filter(array_map(
|
||||||
|
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
|
||||||
|
$runMap,
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($runIds === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->whereIn('id', $runIds)
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->whereIn('outcome', [
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function completionSummarySelectedBootstrapTypes(): array
|
||||||
|
{
|
||||||
|
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($selectedTypes)) {
|
||||||
|
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
|
||||||
|
|
||||||
|
if ($normalized !== []) {
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->selectedBootstrapOperationTypes !== []) {
|
||||||
|
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
||||||
|
|
||||||
|
return is_array($persistedTypes)
|
||||||
|
? $this->normalizeBootstrapOperationTypes($persistedTypes)
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeOnboarding(): void
|
public function completeOnboarding(): void
|
||||||
@ -4049,15 +4367,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
|||||||
tenant: $this->managedTenant,
|
tenant: $this->managedTenant,
|
||||||
action: 'provider_connection.connection_type_changed',
|
action: 'provider_connection.connection_type_changed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'from_connection_type' => $existingType->value,
|
'from_connection_type' => $existingType->value,
|
||||||
'to_connection_type' => $targetType->value,
|
'to_connection_type' => $targetType->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: (int) $user->getKey(),
|
actorId: (int) $user->getKey(),
|
||||||
actorEmail: (string) $user->email,
|
actorEmail: (string) $user->email,
|
||||||
@ -4073,15 +4388,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
|||||||
tenant: $this->managedTenant,
|
tenant: $this->managedTenant,
|
||||||
action: 'provider_connection.updated',
|
action: 'provider_connection.updated',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||||
'provider' => (string) $connection->provider,
|
|
||||||
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
||||||
'fields' => $changedFields,
|
|
||||||
'connection_type' => $targetType->value,
|
'connection_type' => $targetType->value,
|
||||||
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
||||||
],
|
]),
|
||||||
],
|
],
|
||||||
actorId: (int) $user->getKey(),
|
actorId: (int) $user->getKey(),
|
||||||
actorEmail: (string) $user->email,
|
actorEmail: (string) $user->email,
|
||||||
@ -4139,9 +4451,10 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
|
|||||||
private function bootstrapOperationOptions(): array
|
private function bootstrapOperationOptions(): array
|
||||||
{
|
{
|
||||||
$registry = app(ProviderOperationRegistry::class);
|
$registry = app(ProviderOperationRegistry::class);
|
||||||
|
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
||||||
|
|
||||||
return collect($registry->all())
|
return collect($registry->all())
|
||||||
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check')
|
->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
|
||||||
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -840,7 +841,17 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
|||||||
|
|
||||||
private static function profileNextStep(BaselineProfile $profile): string
|
private static function profileNextStep(BaselineProfile $profile): string
|
||||||
{
|
{
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
$compareAvailabilityReason = self::compareAvailabilityReason($profile);
|
||||||
|
|
||||||
|
if ($compareAvailabilityReason === null) {
|
||||||
|
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
|
||||||
|
|
||||||
|
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
|
||||||
|
return $latestCaptureEnvelope->shortExplanation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($compareAvailabilityReason) {
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
||||||
@ -858,6 +869,30 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
|
|||||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('type', 'baseline_capture')
|
||||||
|
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->where('status', 'completed')
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = data_get($run->context, 'reason_code');
|
||||||
|
|
||||||
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
|
||||||
|
}
|
||||||
|
|
||||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||||
{
|
{
|
||||||
$status = $profile->status instanceof BaselineProfileStatus
|
$status = $profile->status instanceof BaselineProfileStatus
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
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\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -105,15 +106,10 @@ private function captureAction(): Action
|
|||||||
|
|
||||||
if (! $result['ok']) {
|
if (! $result['ok']) {
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||||
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
$message = match ($reasonCode) {
|
$message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
|
||||||
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
|
? trim($translation->shortExplanation)
|
||||||
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
: 'Reason: '.str_replace('.', ' ', $reasonCode);
|
||||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
|
|
||||||
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
|
|
||||||
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
|
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
|
||||||
};
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start capture')
|
->title('Cannot start capture')
|
||||||
|
|||||||
@ -26,6 +26,8 @@
|
|||||||
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;
|
||||||
@ -50,6 +52,7 @@
|
|||||||
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
|
||||||
@ -484,6 +487,62 @@ 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
|
||||||
@ -496,11 +555,17 @@ 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('Entra tenant ID')
|
->label('Target scope 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)),
|
||||||
@ -563,8 +628,9 @@ 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('entra_tenant_id')
|
Infolists\Components\TextEntry::make('target_scope')
|
||||||
->label('Entra tenant ID')
|
->label('Target scope')
|
||||||
|
->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')
|
||||||
@ -614,6 +680,11 @@ 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'),
|
||||||
@ -671,9 +742,15 @@ 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')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('provider')
|
||||||
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
->label('Provider')
|
||||||
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
|
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
|
||||||
|
Tables\Columns\TextColumn::make('target_scope')
|
||||||
|
->label('Target scope')
|
||||||
|
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
|
||||||
|
->copyable(),
|
||||||
|
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('connection_type')
|
Tables\Columns\TextColumn::make('connection_type')
|
||||||
->label('Connection type')
|
->label('Connection type')
|
||||||
->badge()
|
->badge()
|
||||||
@ -949,10 +1026,7 @@ public static function makeSetDefaultAction(): Actions\Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.default_set',
|
action: 'provider_connection.default_set',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record),
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -1014,15 +1088,12 @@ 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' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'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,
|
||||||
@ -1161,14 +1232,11 @@ 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' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'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,
|
||||||
@ -1233,14 +1301,12 @@ public static function makeEnableConnectionAction(): Actions\Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.enabled',
|
action: 'provider_connection.enabled',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'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,
|
||||||
@ -1302,12 +1368,10 @@ public static function makeDisableConnectionAction(): Actions\Action
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.disabled',
|
action: 'provider_connection.disabled',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => static::targetScopeAuditMetadata($record, [
|
||||||
'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,
|
||||||
|
|||||||
@ -9,9 +9,12 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -28,6 +31,21 @@ 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,
|
||||||
@ -70,11 +88,9 @@ protected function afterCreate(): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.created',
|
action: 'provider_connection.created',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||||
'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,
|
||||||
|
|||||||
@ -19,6 +19,8 @@
|
|||||||
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;
|
||||||
@ -26,6 +28,7 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -77,6 +80,22 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,11 +138,9 @@ protected function afterSave(): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.updated',
|
action: 'provider_connection.updated',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
|
||||||
'provider' => $record->provider,
|
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
]),
|
||||||
'fields' => $changedFields,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
@ -139,10 +156,7 @@ protected function afterSave(): void
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'provider_connection.default_set',
|
action: 'provider_connection.default_set',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
actorId: $actorId,
|
actorId: $actorId,
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
|
|||||||
@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
|
|||||||
|
|
||||||
public function getTableEmptyStateHeading(): ?string
|
public function getTableEmptyStateHeading(): ?string
|
||||||
{
|
{
|
||||||
return 'No Microsoft connections found';
|
return 'No provider connections found';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTableEmptyStateDescription(): ?string
|
public function getTableEmptyStateDescription(): ?string
|
||||||
{
|
{
|
||||||
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
|
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTableEmptyStateActions(): array
|
public function getTableEmptyStateActions(): array
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
@ -54,6 +55,8 @@ public function __invoke(
|
|||||||
error: $error,
|
error: $error,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->invalidateResumableOnboardingVerificationState($tenant, $connection);
|
||||||
|
|
||||||
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
|
||||||
$auditMetadata = [
|
$auditMetadata = [
|
||||||
'source' => 'admin.consent.callback',
|
'source' => 'admin.consent.callback',
|
||||||
@ -98,6 +101,7 @@ public function __invoke(
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'consentGranted' => $consentGranted,
|
'consentGranted' => $consentGranted,
|
||||||
|
'verificationStateLabel' => $this->verificationStateLabel($connection),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,4 +201,48 @@ private function parseState(?string $state): ?string
|
|||||||
|
|
||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function verificationStateLabel(ProviderConnection $connection): string
|
||||||
|
{
|
||||||
|
$verificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
|
||||||
|
? $connection->verification_status
|
||||||
|
: ProviderVerificationStatus::tryFrom((string) $connection->verification_status);
|
||||||
|
|
||||||
|
if ($verificationStatus === ProviderVerificationStatus::Unknown) {
|
||||||
|
return $connection->consent_status === ProviderConsentStatus::Granted
|
||||||
|
? 'Needs verification'
|
||||||
|
: 'Not verified';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ucfirst(str_replace('_', ' ', $verificationStatus?->value ?? 'unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateResumableOnboardingVerificationState(Tenant $tenant, ProviderConnection $connection): void
|
||||||
|
{
|
||||||
|
TenantOnboardingSession::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->resumable()
|
||||||
|
->each(function (TenantOnboardingSession $draft) use ($connection): void {
|
||||||
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
|
$providerConnectionId = $state['provider_connection_id'] ?? null;
|
||||||
|
$providerConnectionId = is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||||
|
|
||||||
|
if ($providerConnectionId !== null && $providerConnectionId !== (int) $connection->getKey()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset(
|
||||||
|
$state['verification_operation_run_id'],
|
||||||
|
$state['verification_run_id'],
|
||||||
|
$state['bootstrap_operation_runs'],
|
||||||
|
$state['bootstrap_operation_types'],
|
||||||
|
$state['bootstrap_run_ids'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$state['connection_recently_updated'] = true;
|
||||||
|
|
||||||
|
$draft->state = $state;
|
||||||
|
$draft->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
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\Baselines\BaselineCaptureService;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||||
@ -29,7 +30,6 @@
|
|||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -71,13 +71,24 @@ public function handle(
|
|||||||
InventoryMetaContract $metaContract,
|
InventoryMetaContract $metaContract,
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
mixed $arg5 = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
mixed $arg6 = null,
|
||||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$captureService = $arg5 instanceof BaselineCaptureService
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
? $arg5
|
||||||
|
: app(BaselineCaptureService::class);
|
||||||
|
$hashResolver = $arg5 instanceof CurrentStateHashResolver
|
||||||
|
? $arg5
|
||||||
|
: ($arg6 instanceof CurrentStateHashResolver
|
||||||
|
? $arg6
|
||||||
|
: app(CurrentStateHashResolver::class));
|
||||||
|
$contentCapturePhase = $arg5 instanceof BaselineContentCapturePhase
|
||||||
|
? $arg5
|
||||||
|
: ($arg6 instanceof BaselineContentCapturePhase
|
||||||
|
? $arg6
|
||||||
|
: app(BaselineContentCapturePhase::class));
|
||||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
@ -118,10 +129,124 @@ public function handle(
|
|||||||
$rolloutGate->assertEnabled();
|
$rolloutGate->assertEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant);
|
$previousCurrentSnapshot = $profile->resolveCurrentConsumableSnapshot();
|
||||||
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun
|
$previousCurrentSnapshotId = $previousCurrentSnapshot instanceof BaselineSnapshot
|
||||||
? (int) $latestInventorySyncRun->getKey()
|
? (int) $previousCurrentSnapshot->getKey()
|
||||||
: null;
|
: null;
|
||||||
|
$previousCurrentSnapshotExists = $previousCurrentSnapshotId !== null;
|
||||||
|
|
||||||
|
$preflightEligibility = is_array(data_get($context, 'baseline_capture.eligibility'))
|
||||||
|
? data_get($context, 'baseline_capture.eligibility')
|
||||||
|
: [];
|
||||||
|
$inventoryEligibility = $captureService->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, $truthfulTypes);
|
||||||
|
$latestInventorySyncRunId = is_numeric($inventoryEligibility['inventory_sync_run_id'] ?? null)
|
||||||
|
? (int) $inventoryEligibility['inventory_sync_run_id']
|
||||||
|
: null;
|
||||||
|
$eligibilityContext = $captureService->eligibilityContextPayload($inventoryEligibility, phase: 'runtime_recheck');
|
||||||
|
$eligibilityContext['changed_after_enqueue'] = ($preflightEligibility['ok'] ?? null) === true
|
||||||
|
&& ! ($inventoryEligibility['ok'] ?? false);
|
||||||
|
$eligibilityContext['preflight_inventory_sync_run_id'] = is_numeric($preflightEligibility['inventory_sync_run_id'] ?? null)
|
||||||
|
? (int) $preflightEligibility['inventory_sync_run_id']
|
||||||
|
: null;
|
||||||
|
$eligibilityContext['preflight_reason_code'] = is_string($preflightEligibility['reason_code'] ?? null)
|
||||||
|
? (string) $preflightEligibility['reason_code']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$context['baseline_capture'] = array_merge(
|
||||||
|
is_array($context['baseline_capture'] ?? null) ? $context['baseline_capture'] : [],
|
||||||
|
[
|
||||||
|
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
||||||
|
'eligibility' => $eligibilityContext,
|
||||||
|
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
||||||
|
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->operationRun->update(['context' => $context]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
|
||||||
|
if (! ($inventoryEligibility['ok'] ?? false)) {
|
||||||
|
$reasonCode = is_string($inventoryEligibility['reason_code'] ?? null)
|
||||||
|
? (string) $inventoryEligibility['reason_code']
|
||||||
|
: BaselineReasonCodes::CAPTURE_INVENTORY_MISSING;
|
||||||
|
$summaryCounts = [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
$blockedContext = $context;
|
||||||
|
$blockedContext['reason_code'] = $reasonCode;
|
||||||
|
$blockedContext['baseline_capture'] = array_merge(
|
||||||
|
is_array($blockedContext['baseline_capture'] ?? null) ? $blockedContext['baseline_capture'] : [],
|
||||||
|
[
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'subjects_total' => 0,
|
||||||
|
'current_baseline_changed' => false,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$blockedContext['result'] = array_merge(
|
||||||
|
is_array($blockedContext['result'] ?? null) ? $blockedContext['result'] : [],
|
||||||
|
[
|
||||||
|
'current_baseline_changed' => false,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRun->update([
|
||||||
|
'context' => $blockedContext,
|
||||||
|
'summary_counts' => $summaryCounts,
|
||||||
|
]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
$this->auditStarted(
|
||||||
|
auditLogger: $auditLogger,
|
||||||
|
tenant: $sourceTenant,
|
||||||
|
profile: $profile,
|
||||||
|
initiator: $initiator,
|
||||||
|
captureMode: $captureMode,
|
||||||
|
subjectsTotal: 0,
|
||||||
|
effectiveScope: $effectiveScope,
|
||||||
|
inventorySyncRunId: $latestInventorySyncRunId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$operationRunService->finalizeBlockedRun(
|
||||||
|
run: $this->operationRun,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
message: $this->blockedInventoryMessage(
|
||||||
|
$reasonCode,
|
||||||
|
(bool) ($eligibilityContext['changed_after_enqueue'] ?? false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
$this->auditCompleted(
|
||||||
|
auditLogger: $auditLogger,
|
||||||
|
tenant: $sourceTenant,
|
||||||
|
profile: $profile,
|
||||||
|
snapshot: null,
|
||||||
|
initiator: $initiator,
|
||||||
|
captureMode: $captureMode,
|
||||||
|
subjectsTotal: 0,
|
||||||
|
inventorySyncRunId: $latestInventorySyncRunId,
|
||||||
|
wasNewSnapshot: false,
|
||||||
|
evidenceCaptureStats: [
|
||||||
|
'requested' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'throttled' => 0,
|
||||||
|
],
|
||||||
|
gaps: [
|
||||||
|
'count' => 0,
|
||||||
|
'by_reason' => [],
|
||||||
|
],
|
||||||
|
currentBaselineChanged: false,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$inventoryResult = $this->collectInventorySubjects(
|
$inventoryResult = $this->collectInventorySubjects(
|
||||||
sourceTenant: $sourceTenant,
|
sourceTenant: $sourceTenant,
|
||||||
@ -154,6 +279,7 @@ public function handle(
|
|||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
'throttled' => 0,
|
'throttled' => 0,
|
||||||
];
|
];
|
||||||
|
$phaseResult = [];
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
@ -222,6 +348,91 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($subjectsTotal === 0) {
|
||||||
|
$snapshotResult = $this->captureNoDataSnapshotArtifact(
|
||||||
|
$profile,
|
||||||
|
$identityHash,
|
||||||
|
$snapshotSummary,
|
||||||
|
);
|
||||||
|
$snapshot = $snapshotResult['snapshot'];
|
||||||
|
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||||
|
$summaryCounts = [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
'succeeded' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$updatedContext['reason_code'] = BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS;
|
||||||
|
$updatedContext['baseline_capture'] = array_merge(
|
||||||
|
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
|
||||||
|
[
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||||
|
'subjects_total' => 0,
|
||||||
|
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
||||||
|
'evidence_capture' => $phaseStats,
|
||||||
|
'gaps' => [
|
||||||
|
'count' => $gapsCount,
|
||||||
|
'by_reason' => $gapsByReason,
|
||||||
|
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||||
|
? array_values($phaseResult['gap_subjects'])
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
'resume_token' => $resumeToken,
|
||||||
|
'current_baseline_changed' => false,
|
||||||
|
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
||||||
|
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$updatedContext['result'] = array_merge(
|
||||||
|
is_array($updatedContext['result'] ?? null) ? $updatedContext['result'] : [],
|
||||||
|
[
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'snapshot_identity_hash' => $identityHash,
|
||||||
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
|
'items_captured' => 0,
|
||||||
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
|
'current_baseline_changed' => false,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRun->update([
|
||||||
|
'context' => $updatedContext,
|
||||||
|
'summary_counts' => $summaryCounts,
|
||||||
|
]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
$this->auditCompleted(
|
||||||
|
auditLogger: $auditLogger,
|
||||||
|
tenant: $sourceTenant,
|
||||||
|
profile: $profile,
|
||||||
|
snapshot: $snapshot,
|
||||||
|
initiator: $initiator,
|
||||||
|
captureMode: $captureMode,
|
||||||
|
subjectsTotal: 0,
|
||||||
|
inventorySyncRunId: $latestInventorySyncRunId,
|
||||||
|
wasNewSnapshot: $wasNewSnapshot,
|
||||||
|
evidenceCaptureStats: $phaseStats,
|
||||||
|
gaps: [
|
||||||
|
'count' => $gapsCount,
|
||||||
|
'by_reason' => $gapsByReason,
|
||||||
|
],
|
||||||
|
currentBaselineChanged: false,
|
||||||
|
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$snapshotResult = $this->captureSnapshotArtifact(
|
$snapshotResult = $this->captureSnapshotArtifact(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
@ -236,6 +447,9 @@ public function handle(
|
|||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$profile->refresh();
|
||||||
|
$currentBaselineChanged = $this->currentBaselineChanged($profile, $previousCurrentSnapshotId);
|
||||||
|
|
||||||
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
||||||
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
||||||
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
||||||
@ -269,6 +483,9 @@ public function handle(
|
|||||||
: null,
|
: null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
|
'current_baseline_changed' => $currentBaselineChanged,
|
||||||
|
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
|
||||||
|
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
$updatedContext['result'] = [
|
$updatedContext['result'] = [
|
||||||
@ -277,6 +494,7 @@ public function handle(
|
|||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'items_captured' => $snapshotItems['items_count'],
|
'items_captured' => $snapshotItems['items_count'],
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
|
'current_baseline_changed' => $currentBaselineChanged,
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -295,6 +513,8 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
],
|
],
|
||||||
|
currentBaselineChanged: $currentBaselineChanged,
|
||||||
|
reasonCode: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -651,6 +871,51 @@ private function captureSnapshotArtifact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryJsonb
|
||||||
|
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||||
|
*/
|
||||||
|
private function captureNoDataSnapshotArtifact(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
string $identityHash,
|
||||||
|
array $summaryJsonb,
|
||||||
|
): array {
|
||||||
|
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, 0);
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: 0,
|
||||||
|
persistedItems: 0,
|
||||||
|
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
$snapshot->markIncomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS, [
|
||||||
|
'expected_identity_hash' => $identityHash,
|
||||||
|
'expected_items' => 0,
|
||||||
|
'persisted_items' => 0,
|
||||||
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'was_empty_capture' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot->refresh();
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: 0,
|
||||||
|
persistedItems: 0,
|
||||||
|
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'snapshot' => $snapshot,
|
||||||
|
'was_new_snapshot' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||||
{
|
{
|
||||||
$existing = BaselineSnapshot::query()
|
$existing = BaselineSnapshot::query()
|
||||||
@ -783,6 +1048,32 @@ private function countByPolicyType(array $items): array
|
|||||||
return $counts;
|
return $counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentBaselineChanged(BaselineProfile $profile, ?int $previousCurrentSnapshotId): bool
|
||||||
|
{
|
||||||
|
$currentSnapshot = $profile->resolveCurrentConsumableSnapshot();
|
||||||
|
$currentSnapshotId = $currentSnapshot instanceof BaselineSnapshot
|
||||||
|
? (int) $currentSnapshot->getKey()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $currentSnapshotId !== null && $currentSnapshotId !== $previousCurrentSnapshotId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blockedInventoryMessage(string $reasonCode, bool $changedAfterEnqueue): string
|
||||||
|
{
|
||||||
|
return match ($reasonCode) {
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
|
||||||
|
? 'Capture blocked because the latest inventory sync changed after the run was queued.'
|
||||||
|
: 'Capture blocked because the latest inventory sync was blocked.',
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
|
||||||
|
? 'Capture blocked because the latest inventory sync failed after the run was queued.'
|
||||||
|
: 'Capture blocked because the latest inventory sync failed.',
|
||||||
|
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
|
||||||
|
? 'Capture blocked because the latest inventory coverage became unusable after the run was queued.'
|
||||||
|
: 'Capture blocked because the latest inventory coverage was not usable for this baseline scope.',
|
||||||
|
default => 'Capture blocked because no credible inventory basis was available.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function auditStarted(
|
private function auditStarted(
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -820,7 +1111,7 @@ private function auditCompleted(
|
|||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
BaselineSnapshot $snapshot,
|
?BaselineSnapshot $snapshot,
|
||||||
?User $initiator,
|
?User $initiator,
|
||||||
BaselineCaptureMode $captureMode,
|
BaselineCaptureMode $captureMode,
|
||||||
int $subjectsTotal,
|
int $subjectsTotal,
|
||||||
@ -828,6 +1119,8 @@ private function auditCompleted(
|
|||||||
bool $wasNewSnapshot,
|
bool $wasNewSnapshot,
|
||||||
array $evidenceCaptureStats,
|
array $evidenceCaptureStats,
|
||||||
array $gaps,
|
array $gaps,
|
||||||
|
bool $currentBaselineChanged,
|
||||||
|
?string $reasonCode,
|
||||||
): void {
|
): void {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -841,8 +1134,10 @@ private function auditCompleted(
|
|||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||||
'subjects_total' => $subjectsTotal,
|
'subjects_total' => $subjectsTotal,
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => $snapshot?->getKey(),
|
||||||
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
'snapshot_identity_hash' => $snapshot instanceof BaselineSnapshot ? (string) $snapshot->snapshot_identity_hash : null,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'current_baseline_changed' => $currentBaselineChanged,
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'evidence_capture' => $evidenceCaptureStats,
|
'evidence_capture' => $evidenceCaptureStats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
@ -878,17 +1173,4 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
|
|
||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
|
||||||
{
|
|
||||||
$run = OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::InventorySync->value)
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $run instanceof OperationRun ? $run : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -452,6 +452,11 @@ 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,
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -92,7 +91,7 @@ public function refreshRuns(): void
|
|||||||
$activeCount = (clone $query)->count();
|
$activeCount = (clone $query)->count();
|
||||||
$this->runs = (clone $query)->limit(6)->get();
|
$this->runs = (clone $query)->limit(6)->get();
|
||||||
$this->overflowCount = max(0, $activeCount - 5);
|
$this->overflowCount = max(0, $activeCount - 5);
|
||||||
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
|
$this->hasActiveRuns = $activeCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): \Illuminate\Contracts\View\View
|
public function render(): \Illuminate\Contracts\View\View
|
||||||
|
|||||||
@ -20,21 +20,6 @@ class BaselineProfile extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use BaselineProfileStatus::Draft instead.
|
|
||||||
*/
|
|
||||||
public const string STATUS_DRAFT = 'draft';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use BaselineProfileStatus::Active instead.
|
|
||||||
*/
|
|
||||||
public const string STATUS_ACTIVE = 'active';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use BaselineProfileStatus::Archived instead.
|
|
||||||
*/
|
|
||||||
public const string STATUS_ARCHIVED = 'archived';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'workspace_id',
|
'workspace_id',
|
||||||
|
|||||||
@ -45,4 +45,17 @@ 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)))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,4 +192,17 @@ 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)))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,12 +25,17 @@ public function toDatabase(object $notifiable): array
|
|||||||
{
|
{
|
||||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||||
|
$baselineTruthChanged = data_get($this->run->context, 'baseline_capture.current_baseline_changed');
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
if ($reasonEnvelope !== null) {
|
||||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
$message['reason_translation'] = $reasonEnvelope->toArray();
|
||||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_bool($baselineTruthChanged)) {
|
||||||
|
$message['baseline_truth_changed'] = $baselineTruthChanged;
|
||||||
|
}
|
||||||
|
|
||||||
return $message;
|
return $message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@ -62,6 +65,16 @@ public function startCapture(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$truthfulTypes = $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture')['truthful_types'] ?? null;
|
||||||
|
$inventoryEligibility = $this->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, is_array($truthfulTypes) ? $truthfulTypes : null);
|
||||||
|
|
||||||
|
if (! $inventoryEligibility['ok']) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => $inventoryEligibility['reason_code'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
? $profile->capture_mode
|
? $profile->capture_mode
|
||||||
: BaselineCaptureMode::Opportunistic;
|
: BaselineCaptureMode::Opportunistic;
|
||||||
@ -75,6 +88,10 @@ public function startCapture(
|
|||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
|
'baseline_capture' => [
|
||||||
|
'inventory_sync_run_id' => $inventoryEligibility['inventory_sync_run_id'],
|
||||||
|
'eligibility' => $this->eligibilityContextPayload($inventoryEligibility, phase: 'preflight'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
@ -114,4 +131,134 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $truthfulTypes
|
||||||
|
* @return array{
|
||||||
|
* ok: bool,
|
||||||
|
* reason_code: ?string,
|
||||||
|
* inventory_sync_run_id: ?int,
|
||||||
|
* inventory_outcome: ?string,
|
||||||
|
* effective_types: list<string>,
|
||||||
|
* covered_types: list<string>,
|
||||||
|
* uncovered_types: list<string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function latestInventoryEligibilityDecision(
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
BaselineScope $effectiveScope,
|
||||||
|
?array $truthfulTypes = null,
|
||||||
|
): array {
|
||||||
|
$effectiveTypes = is_array($truthfulTypes) && $truthfulTypes !== []
|
||||||
|
? array_values(array_unique(array_filter($truthfulTypes, 'is_string')))
|
||||||
|
: $effectiveScope->allTypes();
|
||||||
|
|
||||||
|
sort($effectiveTypes, SORT_STRING);
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $sourceTenant->getKey())
|
||||||
|
->where('type', OperationRunType::InventorySync->value)
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
|
||||||
|
'inventory_sync_run_id' => null,
|
||||||
|
'inventory_outcome' => null,
|
||||||
|
'effective_types' => $effectiveTypes,
|
||||||
|
'covered_types' => [],
|
||||||
|
'uncovered_types' => $effectiveTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = is_string($run->outcome) ? trim($run->outcome) : null;
|
||||||
|
|
||||||
|
if ($outcome === OperationRunOutcome::Blocked->value) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
||||||
|
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||||
|
'inventory_outcome' => $outcome,
|
||||||
|
'effective_types' => $effectiveTypes,
|
||||||
|
'covered_types' => [],
|
||||||
|
'uncovered_types' => $effectiveTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($outcome === OperationRunOutcome::Failed->value) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||||
|
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||||
|
'inventory_outcome' => $outcome,
|
||||||
|
'effective_types' => $effectiveTypes,
|
||||||
|
'covered_types' => [],
|
||||||
|
'uncovered_types' => $effectiveTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage = InventoryCoverage::fromContext($run->context);
|
||||||
|
$coveredTypes = $coverage instanceof InventoryCoverage
|
||||||
|
? array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
sort($coveredTypes, SORT_STRING);
|
||||||
|
|
||||||
|
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
|
||||||
|
sort($uncoveredTypes, SORT_STRING);
|
||||||
|
|
||||||
|
if ($coveredTypes === []) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
||||||
|
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||||
|
'inventory_outcome' => $outcome,
|
||||||
|
'effective_types' => $effectiveTypes,
|
||||||
|
'covered_types' => [],
|
||||||
|
'uncovered_types' => $effectiveTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'reason_code' => null,
|
||||||
|
'inventory_sync_run_id' => (int) $run->getKey(),
|
||||||
|
'inventory_outcome' => $outcome,
|
||||||
|
'effective_types' => $effectiveTypes,
|
||||||
|
'covered_types' => $coveredTypes,
|
||||||
|
'uncovered_types' => $uncoveredTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* ok: bool,
|
||||||
|
* reason_code: ?string,
|
||||||
|
* inventory_sync_run_id: ?int,
|
||||||
|
* inventory_outcome: ?string,
|
||||||
|
* effective_types: list<string>,
|
||||||
|
* covered_types: list<string>,
|
||||||
|
* uncovered_types: list<string>
|
||||||
|
* } $decision
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function eligibilityContextPayload(array $decision, string $phase): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'phase' => $phase,
|
||||||
|
'ok' => (bool) ($decision['ok'] ?? false),
|
||||||
|
'reason_code' => is_string($decision['reason_code'] ?? null) ? $decision['reason_code'] : null,
|
||||||
|
'inventory_sync_run_id' => is_numeric($decision['inventory_sync_run_id'] ?? null)
|
||||||
|
? (int) $decision['inventory_sync_run_id']
|
||||||
|
: null,
|
||||||
|
'inventory_outcome' => is_string($decision['inventory_outcome'] ?? null) ? $decision['inventory_outcome'] : null,
|
||||||
|
'effective_types' => array_values(array_filter((array) ($decision['effective_types'] ?? []), 'is_string')),
|
||||||
|
'covered_types' => array_values(array_filter((array) ($decision['covered_types'] ?? []), 'is_string')),
|
||||||
|
'uncovered_types' => array_values(array_filter((array) ($decision['uncovered_types'] ?? []), 'is_string')),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -219,6 +219,9 @@ 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']
|
||||||
: [
|
: [
|
||||||
|
|||||||
@ -10,12 +10,15 @@
|
|||||||
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
|
||||||
@ -36,6 +39,7 @@ 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(),
|
||||||
@ -57,6 +61,7 @@ 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,
|
||||||
];
|
];
|
||||||
@ -81,6 +86,12 @@ 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,
|
||||||
@ -115,6 +126,7 @@ 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(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -133,4 +145,68 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,12 +68,27 @@ public function issueQuery(
|
|||||||
string $reasonFilter = self::FILTER_ALL,
|
string $reasonFilter = self::FILTER_ALL,
|
||||||
bool $applyOrdering = true,
|
bool $applyOrdering = true,
|
||||||
): Builder {
|
): Builder {
|
||||||
$visibleTenants = $this->visibleTenants($workspace, $user);
|
return $this->issueQueryForVisibleTenantIds(
|
||||||
$visibleTenantIds = array_map(
|
$workspace,
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
$this->visibleTenantIds($workspace, $user),
|
||||||
$visibleTenants,
|
$tenantId,
|
||||||
|
$reasonFilter,
|
||||||
|
$applyOrdering,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $visibleTenantIds
|
||||||
|
* @return Builder<Finding>
|
||||||
|
*/
|
||||||
|
private function issueQueryForVisibleTenantIds(
|
||||||
|
Workspace $workspace,
|
||||||
|
array $visibleTenantIds,
|
||||||
|
?int $tenantId = null,
|
||||||
|
string $reasonFilter = self::FILTER_ALL,
|
||||||
|
bool $applyOrdering = true,
|
||||||
|
): Builder {
|
||||||
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
|
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
|
||||||
$visibleTenantIds = [];
|
$visibleTenantIds = [];
|
||||||
} elseif ($tenantId !== null) {
|
} elseif ($tenantId !== null) {
|
||||||
@ -155,9 +170,22 @@ function ($join): void {
|
|||||||
*/
|
*/
|
||||||
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
|
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
|
||||||
{
|
{
|
||||||
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
return $this->summaryForVisibleTenantIds(
|
||||||
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
$workspace,
|
||||||
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
$this->visibleTenantIds($workspace, $user),
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $visibleTenantIds
|
||||||
|
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
|
||||||
|
*/
|
||||||
|
public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleTenantIds, ?int $tenantId = null): array
|
||||||
|
{
|
||||||
|
$allIssues = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
||||||
|
$brokenAssignments = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
||||||
|
$staleInProgress = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'unique_issue_count' => (clone $allIssues)->count(),
|
'unique_issue_count' => (clone $allIssues)->count(),
|
||||||
@ -166,6 +194,17 @@ public function summary(Workspace $workspace, User $user, ?int $tenantId = null)
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
public function visibleTenantIds(Workspace $workspace, User $user): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$this->visibleTenants($workspace, $user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -29,6 +29,8 @@
|
|||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
use App\Support\ReasonTranslation\ReasonTranslator;
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
|
use App\Support\Verification\BlockedVerificationReportFactory;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@ -942,11 +944,23 @@ public function finalizeExecutionLegitimacyBlockedRun(
|
|||||||
'context' => $context,
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->finalizeBlockedRun(
|
$run = $this->finalizeBlockedRun(
|
||||||
run: $run->fresh(),
|
run: $run->fresh(),
|
||||||
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
|
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
|
||||||
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
|
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($run->type === 'provider.connection.check') {
|
||||||
|
VerificationReportWriter::write(
|
||||||
|
run: $run,
|
||||||
|
checks: BlockedVerificationReportFactory::checks($run),
|
||||||
|
identity: BlockedVerificationReportFactory::identity($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
@ -34,6 +35,7 @@ class QueuedExecutionLegitimacyGate
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||||
private readonly TenantOperabilityService $tenantOperabilityService,
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||||
private readonly WriteGateInterface $writeGate,
|
private readonly WriteGateInterface $writeGate,
|
||||||
) {}
|
) {}
|
||||||
@ -71,12 +73,8 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|||||||
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
|
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($context->requiredCapability !== null && $context->tenant instanceof Tenant) {
|
if ($context->requiredCapability !== null) {
|
||||||
$checks['capability'] = $this->capabilityResolver->can(
|
$checks['capability'] = $this->initiatorHasRequiredCapability($context) ? 'passed' : 'failed';
|
||||||
$context->initiator,
|
|
||||||
$context->tenant,
|
|
||||||
$context->requiredCapability,
|
|
||||||
) ? 'passed' : 'failed';
|
|
||||||
|
|
||||||
if ($checks['capability'] === 'failed') {
|
if ($checks['capability'] === 'failed') {
|
||||||
return QueuedExecutionLegitimacyDecision::deny(
|
return QueuedExecutionLegitimacyDecision::deny(
|
||||||
@ -106,7 +104,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
|||||||
tenant: $context->tenant,
|
tenant: $context->tenant,
|
||||||
question: $operabilityQuestion,
|
question: $operabilityQuestion,
|
||||||
workspaceId: $context->workspaceId,
|
workspaceId: $context->workspaceId,
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
lane: $this->laneForContext($context),
|
||||||
);
|
);
|
||||||
|
|
||||||
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
|
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
|
||||||
@ -228,6 +226,35 @@ private function resolveProviderConnectionId(array $context): ?int
|
|||||||
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function initiatorHasRequiredCapability(QueuedExecutionContext $context): bool
|
||||||
|
{
|
||||||
|
if (! $context->initiator instanceof User || ! is_string($context->requiredCapability) || $context->requiredCapability === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($context->requiredCapability, 'workspace')) {
|
||||||
|
if ($context->workspaceId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workspaceCapabilityResolver->can(
|
||||||
|
$context->initiator,
|
||||||
|
$context->run->tenant?->workspace ?? $context->run->workspace()->firstOrFail(),
|
||||||
|
$context->requiredCapability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $context->tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->capabilityResolver->can(
|
||||||
|
$context->initiator,
|
||||||
|
$context->tenant,
|
||||||
|
$context->requiredCapability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@ -262,4 +289,16 @@ private function requiresWriteGate(QueuedExecutionContext $context): bool
|
|||||||
{
|
{
|
||||||
return in_array('write_gate', $context->prerequisiteClasses, true);
|
return in_array('write_gate', $context->prerequisiteClasses, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function laneForContext(QueuedExecutionContext $context): TenantInteractionLane
|
||||||
|
{
|
||||||
|
$runContext = is_array($context->run->context) ? $context->run->context : [];
|
||||||
|
$wizardFlow = data_get($runContext, 'wizard.flow');
|
||||||
|
|
||||||
|
if (is_string($wizardFlow) && trim($wizardFlow) === 'managed_tenant_onboarding') {
|
||||||
|
return TenantInteractionLane::OnboardingWorkflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantInteractionLane::AdministrativeManagement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,19 @@
|
|||||||
|
|
||||||
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'));
|
||||||
@ -22,6 +30,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +42,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,6 +54,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +67,13 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
|
|||||||
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),
|
||||||
|
])))
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ public function enableDedicatedOverride(
|
|||||||
$clientSecret = trim($clientSecret);
|
$clientSecret = trim($clientSecret);
|
||||||
|
|
||||||
if ($clientId === '' || $clientSecret === '') {
|
if ($clientId === '' || $clientSecret === '') {
|
||||||
throw new InvalidArgumentException('Dedicated client_id and client_secret are required.');
|
throw new InvalidArgumentException('Dedicated app (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 {
|
||||||
|
|||||||
@ -4,25 +4,38 @@
|
|||||||
|
|
||||||
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,12 +45,29 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,13 @@
|
|||||||
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
|
||||||
@ -63,11 +65,19 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
|
$targetScope = $this->targetScopeNormalizer->normalizeConnection($connection);
|
||||||
|
|
||||||
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
|
$failureCode = $targetScope['failure_code'] ?? ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT;
|
||||||
|
|
||||||
return ProviderConnectionResolution::blocked(
|
return ProviderConnectionResolution::blocked(
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
|
||||||
'Provider connection is missing target tenant scope.',
|
? ProviderReasonCodes::ProviderBindingUnsupported
|
||||||
'ext.connection_tenant_missing',
|
: ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
$targetScope['message'] ?? 'Provider connection target scope is invalid.',
|
||||||
|
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
|
||||||
|
? 'ext.connection_scope_unsupported'
|
||||||
|
: 'ext.connection_scope_missing',
|
||||||
$connection,
|
$connection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,16 @@
|
|||||||
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,
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -53,6 +55,17 @@ public function request(ProviderConnection $connection, string $method, string $
|
|||||||
*/
|
*/
|
||||||
public function graphOptions(ProviderConnection $connection, array $overrides = []): array
|
public function graphOptions(ProviderConnection $connection, array $overrides = []): array
|
||||||
{
|
{
|
||||||
return $this->identityResolver->resolve($connection)->graphOptions($overrides);
|
$resolution = $this->identityResolver->resolve($connection);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,14 @@
|
|||||||
|
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Support\Str;
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
||||||
use RuntimeException;
|
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
|
||||||
|
|
||||||
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,
|
||||||
@ -20,6 +23,8 @@ 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(
|
||||||
@ -30,6 +35,8 @@ 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,
|
||||||
@ -42,6 +49,10 @@ 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +62,8 @@ 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,
|
||||||
@ -63,29 +76,47 @@ 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -15,12 +17,16 @@ 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(
|
||||||
@ -29,16 +35,20 @@ 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 ($tenantContext === '') {
|
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
||||||
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: 'Provider connection is missing target tenant scope.',
|
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,14 +59,25 @@ 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($tenantContext);
|
return $this->platformResolver->resolve(
|
||||||
|
tenantContext: $tenantContext,
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveDedicatedIdentity($connection, $tenantContext);
|
return $this->resolveDedicatedIdentity(
|
||||||
|
connection: $connection,
|
||||||
|
tenantContext: $tenantContext,
|
||||||
|
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
|
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
|
||||||
@ -77,6 +98,8 @@ 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);
|
||||||
@ -89,6 +112,8 @@ private function resolveDedicatedIdentity(
|
|||||||
? ProviderReasonCodes::DedicatedCredentialInvalid
|
? ProviderReasonCodes::DedicatedCredentialInvalid
|
||||||
: ProviderReasonCodes::DedicatedCredentialMissing,
|
: ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
message: $exception->getMessage(),
|
message: $exception->getMessage(),
|
||||||
|
targetScope: $targetScope,
|
||||||
|
contextualIdentityDetails: $contextualIdentityDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +125,8 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,44 +7,48 @@
|
|||||||
|
|
||||||
final class ProviderOperationRegistry
|
final class ProviderOperationRegistry
|
||||||
{
|
{
|
||||||
|
public const string BINDING_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const string BINDING_UNSUPPORTED = 'unsupported';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array{provider: string, module: string, label: string, required_capability: string}>
|
* @return array<string, array{operation_type: string, module: string, label: string, required_capability: string}>
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function definitions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'provider.connection.check' => [
|
'provider.connection.check' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'provider.connection.check',
|
||||||
'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' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'inventory_sync',
|
||||||
'module' => 'inventory',
|
'module' => 'inventory',
|
||||||
'label' => 'Inventory sync',
|
'label' => 'Inventory sync',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'compliance.snapshot' => [
|
'compliance.snapshot' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'compliance.snapshot',
|
||||||
'module' => 'compliance',
|
'module' => 'compliance',
|
||||||
'label' => 'Compliance snapshot',
|
'label' => 'Compliance snapshot',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'restore.execute' => [
|
'restore.execute' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'restore.execute',
|
||||||
'module' => 'restore',
|
'module' => 'restore',
|
||||||
'label' => 'Restore execution',
|
'label' => 'Restore execution',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
'entra_group_sync' => [
|
'entra_group_sync' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'entra_group_sync',
|
||||||
'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' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'directory_role_definitions.sync',
|
||||||
'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,
|
||||||
@ -52,19 +56,78 @@ public function all(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAllowed(string $operationType): bool
|
/**
|
||||||
|
* @return array<string, array{operation_type: string, module: string, label: string, required_capability: string}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
{
|
{
|
||||||
return array_key_exists($operationType, $this->all());
|
return $this->definitions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{provider: string, module: string, label: string, required_capability: string}
|
* @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.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'entra_group_sync' => [
|
||||||
|
'microsoft' => $this->activeMicrosoftBinding(
|
||||||
|
operationType: 'entra_group_sync',
|
||||||
|
handlerNotes: 'Uses the current Microsoft Entra group synchronization workflow.',
|
||||||
|
exceptionNotes: 'The operation type keeps current Entra vocabulary until the identity-neutrality follow-up.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'directory_role_definitions.sync' => [
|
||||||
|
'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
|
||||||
|
{
|
||||||
|
return array_key_exists(trim($operationType), $this->definitions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{operation_type: 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->all()[$operationType] ?? null;
|
$definition = $this->definitions()[$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}");
|
||||||
@ -72,4 +135,85 @@ 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,26 +42,47 @@ 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) $definition['provider'], $connection)
|
? $this->resolver->validateConnection($tenant, (string) $binding['provider'], $connection)
|
||||||
: $this->resolver->resolveDefault($tenant, (string) $definition['provider']);
|
: $this->resolver->resolveDefault($tenant, (string) $binding['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) $definition['provider'],
|
provider: (string) $binding['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: $extraContext,
|
extraContext: array_merge($extraContext, [
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $resolution): ProviderOperationStartResult {
|
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult {
|
||||||
$connection = $resolution->connection;
|
$connection = $resolution->connection;
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
@ -114,6 +135,7 @@ 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,
|
||||||
@ -235,6 +257,36 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -65,6 +65,9 @@ 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', []),
|
||||||
|
|||||||
@ -55,6 +55,7 @@ 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);
|
||||||
@ -70,6 +71,7 @@ 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)),
|
||||||
]));
|
]));
|
||||||
@ -96,6 +98,8 @@ 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' => [
|
||||||
@ -145,6 +149,7 @@ 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,
|
||||||
@ -178,6 +183,7 @@ 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,
|
||||||
@ -293,6 +299,20 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -119,6 +119,11 @@ 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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -38,7 +38,6 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
|
||||||
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
|
||||||
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
|
||||||
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
|
|
||||||
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
|
||||||
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
|
||||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||||
|
|||||||
@ -29,7 +29,6 @@ enum BadgeDomain: string
|
|||||||
case BooleanEnabled = 'boolean_enabled';
|
case BooleanEnabled = 'boolean_enabled';
|
||||||
case BooleanHasErrors = 'boolean_has_errors';
|
case BooleanHasErrors = 'boolean_has_errors';
|
||||||
case TenantStatus = 'tenant_status';
|
case TenantStatus = 'tenant_status';
|
||||||
case TenantAppStatus = 'tenant_app_status';
|
|
||||||
case TenantRbacStatus = 'tenant_rbac_status';
|
case TenantRbacStatus = 'tenant_rbac_status';
|
||||||
case TenantPermissionStatus = 'tenant_permission_status';
|
case TenantPermissionStatus = 'tenant_permission_status';
|
||||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class TenantAppStatusBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
|
|
||||||
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
|
||||||
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@ -120,7 +121,8 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
$latestCaptureRun = self::latestBaselineCaptureRun($profile);
|
||||||
|
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode, $latestCaptureRun);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$profileScope = $profile->normalizedScope();
|
$profileScope = $profile->normalizedScope();
|
||||||
@ -905,8 +907,35 @@ private static function empty(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function missingSnapshotMessage(?string $reasonCode): ?string
|
private static function latestBaselineCaptureRun(BaselineProfile $profile): ?OperationRun
|
||||||
{
|
{
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('type', OperationRunType::BaselineCapture->value)
|
||||||
|
->where('context->baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function missingSnapshotMessage(?string $reasonCode, ?OperationRun $latestCaptureRun = null): ?string
|
||||||
|
{
|
||||||
|
$latestCaptureEnvelope = $latestCaptureRun instanceof OperationRun
|
||||||
|
? app(ReasonPresenter::class)->forOperationRun($latestCaptureRun, 'artifact_truth')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($latestCaptureEnvelope !== null
|
||||||
|
&& in_array($latestCaptureEnvelope->internalCode, [
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||||
|
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
|
||||||
|
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||||
|
], true)
|
||||||
|
&& trim($latestCaptureEnvelope->shortExplanation) !== '') {
|
||||||
|
return $latestCaptureEnvelope->shortExplanation;
|
||||||
|
}
|
||||||
|
|
||||||
return match ($reasonCode) {
|
return match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
||||||
|
|||||||
@ -22,6 +22,16 @@ final class BaselineReasonCodes
|
|||||||
|
|
||||||
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
||||||
|
|
||||||
|
public const string CAPTURE_INVENTORY_MISSING = 'baseline.capture.inventory_missing';
|
||||||
|
|
||||||
|
public const string CAPTURE_INVENTORY_BLOCKED = 'baseline.capture.inventory_blocked';
|
||||||
|
|
||||||
|
public const string CAPTURE_INVENTORY_FAILED = 'baseline.capture.inventory_failed';
|
||||||
|
|
||||||
|
public const string CAPTURE_UNUSABLE_COVERAGE = 'baseline.capture.unusable_coverage';
|
||||||
|
|
||||||
|
public const string CAPTURE_ZERO_SUBJECTS = 'baseline.capture.zero_subjects';
|
||||||
|
|
||||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
||||||
|
|
||||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
||||||
@ -73,6 +83,11 @@ public static function all(): array
|
|||||||
self::CAPTURE_ROLLOUT_DISABLED,
|
self::CAPTURE_ROLLOUT_DISABLED,
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||||
|
self::CAPTURE_INVENTORY_MISSING,
|
||||||
|
self::CAPTURE_INVENTORY_BLOCKED,
|
||||||
|
self::CAPTURE_INVENTORY_FAILED,
|
||||||
|
self::CAPTURE_UNUSABLE_COVERAGE,
|
||||||
|
self::CAPTURE_ZERO_SUBJECTS,
|
||||||
self::SNAPSHOT_BUILDING,
|
self::SNAPSHOT_BUILDING,
|
||||||
self::SNAPSHOT_INCOMPLETE,
|
self::SNAPSHOT_INCOMPLETE,
|
||||||
self::SNAPSHOT_SUPERSEDED,
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
@ -128,7 +143,12 @@ public static function trustImpact(?string $reasonCode): ?string
|
|||||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable',
|
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||||
|
self::CAPTURE_INVENTORY_MISSING,
|
||||||
|
self::CAPTURE_INVENTORY_BLOCKED,
|
||||||
|
self::CAPTURE_INVENTORY_FAILED,
|
||||||
|
self::CAPTURE_UNUSABLE_COVERAGE,
|
||||||
|
self::CAPTURE_ZERO_SUBJECTS => 'unusable',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -148,6 +168,10 @@ public static function absencePattern(?string $reasonCode): ?string
|
|||||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||||
self::CAPTURE_ROLLOUT_DISABLED,
|
self::CAPTURE_ROLLOUT_DISABLED,
|
||||||
|
self::CAPTURE_INVENTORY_MISSING,
|
||||||
|
self::CAPTURE_INVENTORY_BLOCKED,
|
||||||
|
self::CAPTURE_INVENTORY_FAILED,
|
||||||
|
self::CAPTURE_UNUSABLE_COVERAGE,
|
||||||
self::COMPARE_NO_ASSIGNMENT,
|
self::COMPARE_NO_ASSIGNMENT,
|
||||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||||
@ -159,6 +183,7 @@ public static function absencePattern(?string $reasonCode): ?string
|
|||||||
self::SNAPSHOT_SUPERSEDED,
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||||
|
self::CAPTURE_ZERO_SUBJECTS => 'missing_input',
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
||||||
default => null,
|
default => null,
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
<?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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ public static function existForTenantId(?int $tenantId): bool
|
|||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->active()
|
->healthyActive()
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
@ -141,9 +142,37 @@ private function baselineCaptureHeadline(
|
|||||||
array $counts,
|
array $counts,
|
||||||
?OperatorExplanationPattern $operatorExplanation,
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
): string {
|
): string {
|
||||||
|
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
|
||||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||||
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
||||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||||
|
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
|
||||||
|
return 'The baseline capture could not continue because no current inventory basis was available.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
|
||||||
|
return $changedAfterEnqueue
|
||||||
|
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
|
||||||
|
: 'The baseline capture was blocked because the latest inventory sync was blocked.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
|
||||||
|
return $changedAfterEnqueue
|
||||||
|
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
|
||||||
|
: 'The baseline capture was blocked because the latest inventory sync failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
|
||||||
|
return $changedAfterEnqueue
|
||||||
|
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
|
||||||
|
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
||||||
|
return 'The baseline capture finished without a usable baseline because no governed subjects were in scope.';
|
||||||
|
}
|
||||||
|
|
||||||
if ($subjectsTotal === 0) {
|
if ($subjectsTotal === 0) {
|
||||||
return 'No baseline was captured because no governed subjects were ready.';
|
return 'No baseline was captured because no governed subjects were ready.';
|
||||||
@ -629,9 +658,55 @@ private function pushCandidate(array &$candidates, ?string $code, ?string $label
|
|||||||
*/
|
*/
|
||||||
private function baselineCaptureCandidates(array &$candidates, array $context): void
|
private function baselineCaptureCandidates(array &$candidates, array $context): void
|
||||||
{
|
{
|
||||||
|
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
|
||||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||||
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
||||||
|
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
|
||||||
|
$this->pushCandidate($candidates, $reasonCode, 'Run tenant sync first', 'No current inventory basis was available for this baseline capture.', 95);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
|
||||||
|
$this->pushCandidate(
|
||||||
|
$candidates,
|
||||||
|
$reasonCode,
|
||||||
|
'Latest inventory sync was blocked',
|
||||||
|
$changedAfterEnqueue
|
||||||
|
? 'The latest inventory sync changed after the run was queued and blocked the capture.'
|
||||||
|
: 'The latest inventory sync was blocked before this capture could produce a trustworthy baseline.',
|
||||||
|
95,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
|
||||||
|
$this->pushCandidate(
|
||||||
|
$candidates,
|
||||||
|
$reasonCode,
|
||||||
|
'Latest inventory sync failed',
|
||||||
|
$changedAfterEnqueue
|
||||||
|
? 'The latest inventory sync failed after the run was queued, so the capture stopped without refreshing baseline truth.'
|
||||||
|
: 'The latest inventory sync failed before this capture could produce a trustworthy baseline.',
|
||||||
|
95,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
|
||||||
|
$this->pushCandidate(
|
||||||
|
$candidates,
|
||||||
|
$reasonCode,
|
||||||
|
'Latest inventory coverage unusable',
|
||||||
|
$changedAfterEnqueue
|
||||||
|
? 'The latest inventory coverage became unusable after the run was queued, so the capture stopped without refreshing baseline truth.'
|
||||||
|
: 'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
|
||||||
|
95,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
||||||
|
$this->pushCandidate($candidates, $reasonCode, 'No subjects were in scope', 'No governed subjects were available for this baseline capture.', 95);
|
||||||
|
}
|
||||||
|
|
||||||
if ($subjectsTotal === 0) {
|
if ($subjectsTotal === 0) {
|
||||||
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
|
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
|
||||||
|
|||||||
@ -547,6 +547,11 @@ private static function terminalSupportingLines(OperationRun $run): array
|
|||||||
$lines[] = $guidance;
|
$lines[] = $guidance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$baselineTruthChange = self::baselineTruthChangeLine($run);
|
||||||
|
if ($baselineTruthChange !== null) {
|
||||||
|
$lines[] = $baselineTruthChange;
|
||||||
|
}
|
||||||
|
|
||||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||||
if ($summary !== null) {
|
if ($summary !== null) {
|
||||||
$lines[] = $summary;
|
$lines[] = $summary;
|
||||||
@ -560,6 +565,25 @@ private static function terminalSupportingLines(OperationRun $run): array
|
|||||||
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function baselineTruthChangeLine(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
if ((string) $run->type !== 'baseline_capture') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = data_get($run->context, 'baseline_capture.current_baseline_changed');
|
||||||
|
|
||||||
|
if ($changed === true) {
|
||||||
|
return 'Current baseline truth was updated.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed === false) {
|
||||||
|
return 'Current baseline truth was unchanged.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{label:string, url:?string, target:string}
|
* @return array{label:string, url:?string, target:string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,194 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
<?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), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,8 @@ 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';
|
||||||
@ -77,6 +79,7 @@ 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,
|
||||||
@ -139,6 +142,7 @@ 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -141,6 +141,13 @@ 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',
|
||||||
@ -284,7 +291,8 @@ 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
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
<?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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
<?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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,7 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
|||||||
|
|
||||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||||
?? data_get($context, 'reason_code')
|
?? data_get($context, 'reason_code')
|
||||||
|
?? data_get($context, 'baseline_capture.reason_code')
|
||||||
?? data_get($context, 'baseline_compare.reason_code');
|
?? data_get($context, 'baseline_compare.reason_code');
|
||||||
|
|
||||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
||||||
|
|||||||
@ -51,8 +51,8 @@ public function translate(
|
|||||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||||
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
|
||||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
|
||||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
||||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||||
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||||
@ -116,7 +116,10 @@ private function fallbackTranslate(
|
|||||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function translateBaselineReason(string $reasonCode, array $context = []): ReasonResolutionEnvelope
|
||||||
{
|
{
|
||||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
|
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
|
||||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
|
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
|
||||||
@ -138,6 +141,51 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
|
|||||||
'prerequisite_missing',
|
'prerequisite_missing',
|
||||||
'Enable the rollout before retrying full-content baseline work.',
|
'Enable the rollout before retrying full-content baseline work.',
|
||||||
],
|
],
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => [
|
||||||
|
'Run tenant sync first',
|
||||||
|
$this->baselineCaptureTruthImpactExplanation(
|
||||||
|
'No current inventory basis was available for this baseline capture.',
|
||||||
|
$context,
|
||||||
|
),
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Run inventory sync for this tenant, then capture the baseline again.',
|
||||||
|
],
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => [
|
||||||
|
'Latest inventory sync was blocked',
|
||||||
|
$this->baselineCaptureTruthImpactExplanation(
|
||||||
|
'The latest inventory sync was blocked, so this capture could not use a credible upstream basis.',
|
||||||
|
$context,
|
||||||
|
),
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Review the blocked inventory sync, fix the prerequisite, and rerun sync before capturing again.',
|
||||||
|
],
|
||||||
|
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => [
|
||||||
|
'Latest inventory sync failed',
|
||||||
|
$this->baselineCaptureTruthImpactExplanation(
|
||||||
|
'The latest inventory sync failed, so this capture could not use a credible upstream basis.',
|
||||||
|
$context,
|
||||||
|
),
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Review the failed inventory sync, fix the error, and rerun sync before capturing again.',
|
||||||
|
],
|
||||||
|
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => [
|
||||||
|
'Latest inventory coverage unusable',
|
||||||
|
$this->baselineCaptureTruthImpactExplanation(
|
||||||
|
'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
|
||||||
|
$context,
|
||||||
|
),
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Run inventory sync until the governed subject types show current coverage, then capture again.',
|
||||||
|
],
|
||||||
|
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS => [
|
||||||
|
'No subjects were in scope',
|
||||||
|
$this->baselineCaptureTruthImpactExplanation(
|
||||||
|
'The latest inventory basis was credible, but no governed subjects were in scope for this baseline capture.',
|
||||||
|
$context,
|
||||||
|
),
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Review the baseline scope and tenant inventory, then capture again when governed subjects are available.',
|
||||||
|
],
|
||||||
BaselineReasonCodes::SNAPSHOT_BUILDING,
|
BaselineReasonCodes::SNAPSHOT_BUILDING,
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
|
||||||
'Baseline still building',
|
'Baseline still building',
|
||||||
@ -242,6 +290,29 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function baselineCaptureTruthImpactExplanation(string $baseExplanation, array $context): string
|
||||||
|
{
|
||||||
|
$changed = data_get($context, 'baseline_capture.current_baseline_changed');
|
||||||
|
$previousSnapshotExists = data_get($context, 'baseline_capture.previous_current_snapshot_exists');
|
||||||
|
|
||||||
|
if ($changed === true) {
|
||||||
|
return $baseExplanation.' TenantPilot updated the current baseline truth with a new consumable snapshot.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($previousSnapshotExists === true) {
|
||||||
|
return $baseExplanation.' TenantPilot kept the last trustworthy baseline in place.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($previousSnapshotExists === false) {
|
||||||
|
return $baseExplanation.' No current trustworthy baseline is available yet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseExplanation;
|
||||||
|
}
|
||||||
|
|
||||||
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
|
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
|
||||||
{
|
{
|
||||||
$enum = BaselineCompareReasonCode::tryFrom($reasonCode);
|
$enum = BaselineCompareReasonCode::tryFrom($reasonCode);
|
||||||
|
|||||||
@ -71,6 +71,7 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
$this->capabilityResolver->primeMemberships($user, $accessibleTenantIds);
|
$this->capabilityResolver->primeMemberships($user, $accessibleTenantIds);
|
||||||
|
$visibleFindingsTenantIds = $this->visibleFindingTenantIds($accessibleTenants, $user);
|
||||||
|
|
||||||
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||||
$navigationContext = $this->workspaceOverviewNavigationContext();
|
$navigationContext = $this->workspaceOverviewNavigationContext();
|
||||||
@ -136,8 +137,8 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
|
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
|
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $visibleFindingsTenantIds, $user);
|
||||||
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user);
|
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $visibleFindingsTenantIds);
|
||||||
|
|
||||||
$zeroTenantState = null;
|
$zeroTenantState = null;
|
||||||
|
|
||||||
@ -210,18 +211,11 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tenant> $accessibleTenants
|
* @param array<int, int> $visibleTenantIds
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array
|
private function myFindingsSignal(int $workspaceId, array $visibleTenantIds, User $user): array
|
||||||
{
|
{
|
||||||
$visibleTenantIds = $accessibleTenants
|
|
||||||
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
|
|
||||||
->pluck('id')
|
|
||||||
->map(static fn (mixed $id): int => (int) $id)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$assignedCounts = $visibleTenantIds === []
|
$assignedCounts = $visibleTenantIds === []
|
||||||
? null
|
? null
|
||||||
: $this->scopeToVisibleTenants(
|
: $this->scopeToVisibleTenants(
|
||||||
@ -271,9 +265,9 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function findingsHygieneSignal(Workspace $workspace, User $user): array
|
private function findingsHygieneSignal(Workspace $workspace, array $visibleTenantIds): array
|
||||||
{
|
{
|
||||||
$summary = $this->findingAssignmentHygieneService->summary($workspace, $user);
|
$summary = $this->findingAssignmentHygieneService->summaryForVisibleTenantIds($workspace, $visibleTenantIds);
|
||||||
$uniqueIssueCount = $summary['unique_issue_count'];
|
$uniqueIssueCount = $summary['unique_issue_count'];
|
||||||
$brokenAssignmentCount = $summary['broken_assignment_count'];
|
$brokenAssignmentCount = $summary['broken_assignment_count'];
|
||||||
$staleInProgressCount = $summary['stale_in_progress_count'];
|
$staleInProgressCount = $summary['stale_in_progress_count'];
|
||||||
@ -297,6 +291,20 @@ private function findingsHygieneSignal(Workspace $workspace, User $user): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Tenant> $accessibleTenants
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function visibleFindingTenantIds(Collection $accessibleTenants, User $user): array
|
||||||
|
{
|
||||||
|
return $accessibleTenants
|
||||||
|
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
|
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
|
||||||
{
|
{
|
||||||
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {
|
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {
|
||||||
|
|||||||
304
apps/platform/config/canonical_controls.php
Normal file
304
apps/platform/config/canonical_controls.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
115
apps/platform/config/provider_boundaries.php
Normal file
115
apps/platform/config/provider_boundaries.php
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<?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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@ -42,7 +42,6 @@ public function definition(): array
|
|||||||
'app_client_id' => fake()->uuid(),
|
'app_client_id' => fake()->uuid(),
|
||||||
'app_client_secret' => null, // Skip encryption in tests
|
'app_client_secret' => null, // Skip encryption in tests
|
||||||
'app_certificate_thumbprint' => null,
|
'app_certificate_thumbprint' => null,
|
||||||
'app_status' => 'ok',
|
|
||||||
'app_notes' => null,
|
'app_notes' => null,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'environment' => 'other',
|
'environment' => 'other',
|
||||||
|
|||||||
@ -156,7 +156,9 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
|
|||||||
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
|
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($expectedItems !== null && $expectedItems === $persistedItems) {
|
if ($expectedItems !== null
|
||||||
|
&& $expectedItems === $persistedItems
|
||||||
|
&& ! ($expectedItems === 0 && $persistedItems === 0)) {
|
||||||
return [
|
return [
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||||
@ -167,7 +169,10 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) {
|
if ($producerSucceeded
|
||||||
|
&& $producerExpectedItems !== null
|
||||||
|
&& $producerExpectedItems === $persistedItems
|
||||||
|
&& ! ($producerExpectedItems === 0 && $persistedItems === 0)) {
|
||||||
return [
|
return [
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||||
@ -184,11 +189,11 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
|
|||||||
$producerSubjectsTotal,
|
$producerSubjectsTotal,
|
||||||
], static fn (?int $value): bool => $value !== null), true)) {
|
], static fn (?int $value): bool => $value !== null), true)) {
|
||||||
return [
|
return [
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
'completed_at' => null,
|
||||||
'failed_at' => null,
|
'failed_at' => $producerRun->completed_at ?? $row->updated_at ?? $row->captured_at ?? $row->created_at,
|
||||||
'completion_meta' => $completionMeta + [
|
'completion_meta' => $completionMeta + [
|
||||||
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof',
|
'finalization_reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
<p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p>
|
<p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p>
|
||||||
@isset($connection)
|
@isset($connection)
|
||||||
<p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p>
|
<p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p>
|
||||||
<p><strong>Verification state:</strong> {{ ucfirst($connection->verification_status->value) }}</p>
|
<p><strong>Verification state:</strong> {{ $verificationStateLabel ?? ucfirst($connection->verification_status->value) }}</p>
|
||||||
@endisset
|
@endisset
|
||||||
<p>
|
<p>
|
||||||
<span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}">
|
<span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}">
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
$operationsIndexUrl = route('admin.operations.index');
|
$operationsIndexUrl = OperationRunLinks::index($tenant);
|
||||||
|
|
||||||
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -20,6 +22,8 @@
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
$response->assertSeeText('Verification state:');
|
||||||
|
$response->assertSeeText('Needs verification');
|
||||||
$response->assertSee(
|
$response->assertSee(
|
||||||
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
|
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
|
||||||
false,
|
false,
|
||||||
@ -60,6 +64,57 @@
|
|||||||
$response->assertSee(route('admin.onboarding'), false);
|
$response->assertSee(route('admin.onboarding'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('invalidates resumable onboarding verification state for the same platform connection after a successful callback', function () {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => 'tenant-verify-reset',
|
||||||
|
'name' => 'Reset Tenant',
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = TenantOnboardingSession::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'current_step' => 'verify',
|
||||||
|
'state' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
'verification_run_id' => (int) $run->getKey(),
|
||||||
|
'bootstrap_operation_runs' => [123, 456],
|
||||||
|
'bootstrap_operation_types' => ['inventory_sync'],
|
||||||
|
'bootstrap_run_ids' => [123, 456],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('admin.consent.callback', [
|
||||||
|
'tenant' => $tenant->tenant_id,
|
||||||
|
'admin_consent' => 'true',
|
||||||
|
]))->assertOk();
|
||||||
|
|
||||||
|
$draft->refresh();
|
||||||
|
|
||||||
|
expect($draft->state['verification_operation_run_id'] ?? null)->toBeNull()
|
||||||
|
->and($draft->state['verification_run_id'] ?? null)->toBeNull()
|
||||||
|
->and($draft->state['bootstrap_operation_runs'] ?? null)->toBeNull()
|
||||||
|
->and($draft->state['bootstrap_operation_types'] ?? null)->toBeNull()
|
||||||
|
->and($draft->state['bootstrap_run_ids'] ?? null)->toBeNull()
|
||||||
|
->and($draft->state['connection_recently_updated'] ?? null)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates tenant and provider connection when callback tenant does not exist', function () {
|
it('creates tenant and provider connection when callback tenant does not exist', function () {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
@ -101,6 +156,8 @@
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
$response->assertSeeText('Verification state:');
|
||||||
|
$response->assertSeeText('Not verified');
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
$connection = ProviderConnection::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
|||||||
@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -63,3 +67,55 @@
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -135,6 +136,35 @@
|
|||||||
->assertSee('Ambiguous matches');
|
->assertSee('Ambiguous matches');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows entitled viewers to open blocked baseline-capture run detail surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'baseline_capture',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'context' => [
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||||
|
'baseline_capture' => [
|
||||||
|
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
|
||||||
|
'current_baseline_changed' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
|
->assertSee('Latest inventory sync failed');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
|
|||||||
@ -18,6 +18,9 @@
|
|||||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
InventoryItem::factory()->create([
|
InventoryItem::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -26,6 +29,7 @@
|
|||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'display_name' => 'Audit Policy A',
|
'display_name' => 'Audit Policy A',
|
||||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
|
|||||||
@ -34,12 +34,9 @@
|
|||||||
'display_name' => 'Isolated Policy',
|
'display_name' => 'Isolated Policy',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lastSeenRun = OperationRun::factory()->create([
|
$lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'deviceConfiguration' => 'succeeded',
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
], attributes: [
|
||||||
'type' => OperationRunType::InventorySync->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,9 @@
|
|||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
$policy = Policy::factory()->create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -41,6 +44,7 @@
|
|||||||
'assignment_target_count' => 1,
|
'assignment_target_count' => 1,
|
||||||
],
|
],
|
||||||
'last_seen_at' => now()->subHour(),
|
'last_seen_at' => now()->subHour(),
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$snapshotPayload = [
|
$snapshotPayload = [
|
||||||
|
|||||||
@ -29,6 +29,9 @@
|
|||||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
$policy = Policy::factory()->create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -50,6 +53,7 @@
|
|||||||
'assignment_target_count' => 1,
|
'assignment_target_count' => 1,
|
||||||
],
|
],
|
||||||
'last_seen_at' => now()->subHour(),
|
'last_seen_at' => now()->subHour(),
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0);
|
expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0);
|
||||||
|
|||||||
@ -34,12 +34,9 @@
|
|||||||
'display_name' => 'Policy Capture Meta',
|
'display_name' => 'Policy Capture Meta',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$lastSeenRun = OperationRun::factory()->create([
|
$lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'deviceConfiguration' => 'succeeded',
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
], attributes: [
|
||||||
'type' => OperationRunType::InventorySync->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\BaselineSnapshotItem;
|
use App\Models\BaselineSnapshotItem;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Services\Baselines\BaselineCaptureService;
|
use App\Services\Baselines\BaselineCaptureService;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\InventoryMetaContract;
|
use App\Services\Baselines\InventoryMetaContract;
|
||||||
@ -18,6 +19,28 @@
|
|||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
function createBaselineCaptureInventoryBasis(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $statusByType,
|
||||||
|
array $attributes = [],
|
||||||
|
): OperationRun {
|
||||||
|
return createInventorySyncOperationRunWithCoverage($tenant, $statusByType, [], $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBaselineCaptureJob(
|
||||||
|
OperationRun $run,
|
||||||
|
?OperationRunService $operationRunService = null,
|
||||||
|
): void {
|
||||||
|
$operationRunService ??= app(OperationRunService::class);
|
||||||
|
|
||||||
|
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(InventoryMetaContract::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$operationRunService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- T031: Capture enqueue + precondition tests ---
|
// --- T031: Capture enqueue + precondition tests ---
|
||||||
|
|
||||||
it('enqueues capture for an active profile and creates an operation run', function () {
|
it('enqueues capture for an active profile and creates an operation run', function () {
|
||||||
@ -29,6 +52,9 @@
|
|||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
/** @var BaselineCaptureService $service */
|
/** @var BaselineCaptureService $service */
|
||||||
$service = app(BaselineCaptureService::class);
|
$service = app(BaselineCaptureService::class);
|
||||||
@ -53,10 +79,119 @@
|
|||||||
expect($effectiveScope['foundation_types'])->toBe([]);
|
expect($effectiveScope['foundation_types'])->toBe([]);
|
||||||
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
|
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
|
||||||
expect($effectiveScope['foundations_included'])->toBeFalse();
|
expect($effectiveScope['foundations_included'])->toBeFalse();
|
||||||
|
expect(data_get($context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||||
|
expect(data_get($context, 'baseline_capture.eligibility.phase'))->toBe('preflight');
|
||||||
|
expect(data_get($context, 'baseline_capture.eligibility.ok'))->toBeTrue();
|
||||||
|
expect(data_get($context, 'baseline_capture.eligibility.covered_types'))->toBe(['deviceConfiguration']);
|
||||||
|
expect(data_get($context, 'baseline_capture.eligibility.uncovered_types'))->toBe([]);
|
||||||
|
|
||||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects capture when no current inventory sync exists', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capture when the latest inventory sync was blocked', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
], [
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'failed',
|
||||||
|
], [
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
], [
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'failed',
|
||||||
|
], [
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceCompliancePolicy' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects capture for a draft profile with reason code', function () {
|
it('rejects capture for a draft profile with reason code', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
@ -126,6 +261,9 @@
|
|||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
$service = app(BaselineCaptureService::class);
|
$service = app(BaselineCaptureService::class);
|
||||||
|
|
||||||
@ -148,6 +286,9 @@
|
|||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
$inventoryA = InventoryItem::factory()->create([
|
$inventoryA = InventoryItem::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
@ -156,6 +297,7 @@
|
|||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'display_name' => 'Policy A',
|
'display_name' => 'Policy A',
|
||||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
$inventoryB = InventoryItem::factory()->create([
|
$inventoryB = InventoryItem::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
@ -164,6 +306,7 @@
|
|||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'display_name' => 'Policy B',
|
'display_name' => 'Policy B',
|
||||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
$inventoryC = InventoryItem::factory()->create([
|
$inventoryC = InventoryItem::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
@ -172,6 +315,7 @@
|
|||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'display_name' => 'Policy C',
|
'display_name' => 'Policy C',
|
||||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -187,13 +331,7 @@
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job = new CaptureBaselineSnapshotJob($run);
|
runBaselineCaptureJob($run, $opService);
|
||||||
$job->handle(
|
|
||||||
app(BaselineSnapshotIdentity::class),
|
|
||||||
app(InventoryMetaContract::class),
|
|
||||||
app(AuditLogger::class),
|
|
||||||
$opService,
|
|
||||||
);
|
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
@ -269,6 +407,14 @@
|
|||||||
expect(data_get($meta, 'meta_contract'))->toBeNull();
|
expect(data_get($meta, 'meta_contract'))->toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||||
|
expect(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('runtime_recheck');
|
||||||
|
expect(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
|
||||||
|
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeFalse();
|
||||||
|
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeTrue();
|
||||||
|
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeFalse();
|
||||||
|
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeTrue();
|
||||||
|
|
||||||
$profile->refresh();
|
$profile->refresh();
|
||||||
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
|
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||||
});
|
});
|
||||||
@ -311,12 +457,16 @@
|
|||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
InventoryItem::factory()->count(2)->create([
|
InventoryItem::factory()->count(2)->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -336,8 +486,7 @@
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job1 = new CaptureBaselineSnapshotJob($run1);
|
(new CaptureBaselineSnapshotJob($run1))->handle($idService, $metaContract, $auditLogger, $opService);
|
||||||
$job1->handle($idService, $metaContract, $auditLogger, $opService);
|
|
||||||
|
|
||||||
$snapshotCountAfterFirst = BaselineSnapshot::query()
|
$snapshotCountAfterFirst = BaselineSnapshot::query()
|
||||||
->where('baseline_profile_id', $profile->getKey())
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
@ -361,8 +510,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$job2 = new CaptureBaselineSnapshotJob($run2);
|
(new CaptureBaselineSnapshotJob($run2))->handle($idService, $metaContract, $auditLogger, $opService);
|
||||||
$job2->handle($idService, $metaContract, $auditLogger, $opService);
|
|
||||||
|
|
||||||
$snapshotCountAfterSecond = BaselineSnapshot::query()
|
$snapshotCountAfterSecond = BaselineSnapshot::query()
|
||||||
->where('baseline_profile_id', $profile->getKey())
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
@ -371,14 +519,68 @@
|
|||||||
expect($snapshotCountAfterSecond)->toBe(1);
|
expect($snapshotCountAfterSecond)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- EC-005: Empty scope produces empty snapshot without errors ---
|
it('blocks a queued capture when the latest inventory basis fails after enqueue and keeps the prior current baseline', function () {
|
||||||
|
|
||||||
it('captures an empty snapshot when no inventory items match the scope', function () {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$previousSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $previousSnapshot->getKey()]);
|
||||||
|
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
], [
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeTrue();
|
||||||
|
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'failed',
|
||||||
|
], [
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $result['run'];
|
||||||
|
runBaselineCaptureJob($run);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
$profile->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('blocked');
|
||||||
|
expect($profile->active_snapshot_id)->toBe((int) $previousSnapshot->getKey());
|
||||||
|
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||||
|
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
|
||||||
|
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
|
||||||
|
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeTrue();
|
||||||
|
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeTrue();
|
||||||
|
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- EC-005: Zero-subject captures stay visible but non-authoritative ---
|
||||||
|
|
||||||
|
it('records a zero-subject capture as partially succeeded with a non-consumable snapshot', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -389,22 +591,22 @@
|
|||||||
context: [
|
context: [
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'source_tenant_id' => (int) $tenant->getKey(),
|
'source_tenant_id' => (int) $tenant->getKey(),
|
||||||
'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
|
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
],
|
],
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job = new CaptureBaselineSnapshotJob($run);
|
runBaselineCaptureJob($run, $opService);
|
||||||
$job->handle(
|
|
||||||
app(BaselineSnapshotIdentity::class),
|
|
||||||
app(InventoryMetaContract::class),
|
|
||||||
app(AuditLogger::class),
|
|
||||||
$opService,
|
|
||||||
);
|
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->outcome)->toBe('succeeded');
|
expect($run->outcome)->toBe('partially_succeeded');
|
||||||
|
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||||
|
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||||
|
expect(data_get($run->context, 'baseline_capture.subjects_total'))->toBe(0);
|
||||||
|
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
|
||||||
|
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
|
||||||
|
expect(data_get($run->context, 'result.snapshot_lifecycle'))->toBe(BaselineSnapshotLifecycleState::Incomplete->value);
|
||||||
|
|
||||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
expect((int) ($counts['total'] ?? 0))->toBe(0);
|
expect((int) ($counts['total'] ?? 0))->toBe(0);
|
||||||
@ -415,7 +617,12 @@
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($snapshot)->not->toBeNull();
|
expect($snapshot)->not->toBeNull();
|
||||||
|
expect($snapshot?->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Incomplete);
|
||||||
|
expect(data_get($snapshot?->completion_meta_jsonb ?? [], 'finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
|
||||||
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
|
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
|
||||||
|
|
||||||
|
$profile->refresh();
|
||||||
|
expect($profile->active_snapshot_id)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('captures all inventory items when scope has empty policy_types (all types)', function () {
|
it('captures all inventory items when scope has empty policy_types (all types)', function () {
|
||||||
@ -425,17 +632,23 @@
|
|||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
|
||||||
|
'deviceCompliancePolicy' => 'succeeded',
|
||||||
|
'deviceConfiguration' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
InventoryItem::factory()->create([
|
InventoryItem::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
InventoryItem::factory()->create([
|
InventoryItem::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'policy_type' => 'deviceCompliancePolicy',
|
'policy_type' => 'deviceCompliancePolicy',
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Foundation types are excluded by default (unless foundation_types is selected).
|
// Foundation types are excluded by default (unless foundation_types is selected).
|
||||||
@ -443,6 +656,7 @@
|
|||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'workspace_id' => $tenant->workspace_id,
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'policy_type' => 'assignmentFilter',
|
'policy_type' => 'assignmentFilter',
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -458,13 +672,7 @@
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job = new CaptureBaselineSnapshotJob($run);
|
runBaselineCaptureJob($run, $opService);
|
||||||
$job->handle(
|
|
||||||
app(BaselineSnapshotIdentity::class),
|
|
||||||
app(InventoryMetaContract::class),
|
|
||||||
app(AuditLogger::class),
|
|
||||||
$opService,
|
|
||||||
);
|
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
expect($run->status)->toBe('completed');
|
expect($run->status)->toBe('completed');
|
||||||
|
|||||||
@ -1335,12 +1335,19 @@
|
|||||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||||
|
tenant: $tenant,
|
||||||
|
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||||
|
);
|
||||||
|
|
||||||
InventoryItem::factory()->create([
|
InventoryItem::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'external_id' => 'policy-a',
|
'external_id' => 'policy-a',
|
||||||
'policy_type' => 'deviceConfiguration',
|
'policy_type' => 'deviceConfiguration',
|
||||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
|
'last_seen_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$operationRuns = app(OperationRunService::class);
|
$operationRuns = app(OperationRunService::class);
|
||||||
@ -1366,18 +1373,6 @@
|
|||||||
|
|
||||||
$captureRun->refresh();
|
$captureRun->refresh();
|
||||||
|
|
||||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
|
||||||
tenant: $tenant,
|
|
||||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
|
||||||
);
|
|
||||||
|
|
||||||
InventoryItem::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->update([
|
|
||||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
|
||||||
'last_seen_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
|
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
|
||||||
expect($snapshotId)->toBeGreaterThan(0);
|
expect($snapshotId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,10 @@
|
|||||||
it('archives baseline profiles for authorized workspace members', function (): void {
|
it('archives baseline profiles for authorized workspace members', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
expect(defined(BaselineProfile::class.'::STATUS_DRAFT'))->toBeFalse()
|
||||||
|
->and(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse()
|
||||||
|
->and(defined(BaselineProfile::class.'::STATUS_ARCHIVED'))->toBeFalse();
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user