Compare commits

..

95 Commits

Author SHA1 Message Date
02e75e1cda feat: harden baseline compare summary trust surfaces (#196)
## Summary
- add a shared baseline compare summary assessment and assessor for compact trust propagation
- harden dashboard, landing, and banner baseline compare surfaces against false all-clear claims
- add focused Pest coverage for dashboard, landing, banner, reason translation, and canonical detail parity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareCoverageBannerTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php

## Notes
- Livewire compliance: Filament v5 / Livewire v4 stack unchanged
- Provider registration: unchanged, Laravel 12 providers remain in bootstrap/providers.php
- Global search: no searchable resource behavior changed
- Destructive actions: none introduced by this change
- Assets: no new assets registered; existing deploy process remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #196
2026-03-27 00:19:53 +00:00
20b6aa6a32 refactor: reduce operation run detail density (#194)
## Summary
- collapse secondary and diagnostic operation-run sections by default to reduce page density
- visually emphasize the primary next step while keeping counts readable but secondary
- keep failures and other actionable detail available without dominating the default reading path

## Testing
- vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #194
2026-03-26 13:23:52 +00:00
c17255f854 feat: implement baseline subject resolution semantics (#193)
## Summary
- add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories
- persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract
- add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`

## Notes
- verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape
- excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00
7d4d607475 feat: add baseline gap details surfaces (#192)
## Summary
- add baseline compare evidence gap detail modeling and a dedicated Livewire table surface
- extend baseline compare landing and operation run detail surfaces to expose evidence gap details and stats
- add spec artifacts for feature 162 and expand feature coverage with focused Filament and baseline tests

## Notes
- branch: `162-baseline-gap-details`
- commit: `a92dd812`
- working tree was clean after push

## Validation
- tests were not run in this step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #192
2026-03-24 19:05:23 +00:00
1f0cc5de56 feat: implement operator explanation layer (#191)
## Summary
- add the shared operator explanation layer with explanation families, trustworthiness semantics, count descriptors, and centralized badge mappings
- adopt explanation-first rendering across baseline compare, governance operation run detail, baseline snapshot presentation, tenant review detail, and review register rows
- extend reason translation, artifact-truth presentation, fallback ops UX messaging, and focused regression coverage for operator explanation semantics

## Testing
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php
- vendor/bin/sail artisan test --compact

## Notes
- Livewire v4 compatible
- panel provider registration remains in bootstrap/providers.php
- no destructive Filament actions were added or changed in this PR
- no new global-search behavior was introduced in this slice

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #191
2026-03-24 11:24:33 +00:00
845d21db6d feat: harden operation lifecycle monitoring (#190)
## Summary
- harden operation-run lifecycle handling with explicit reconciliation policy, stale-run healing, failed-job bridging, and monitoring visibility
- refactor audit log event inspection into a Filament slide-over and remove the stale inline detail/header-action coupling
- align panel theme asset resolution and supporting Filament UI updates, including the rounded 2xl theme token regression fix

## Testing
- ran focused Pest coverage for the affected audit-log inspection flow and related visibility tests
- ran formatting with `vendor/bin/sail bin pint --dirty --format agent`
- manually verified the updated audit-log slide-over flow in the integrated browser

## Notes
- branch includes the Spec 160 artifacts under `specs/160-operation-lifecycle-guarantees/`
- the full test suite was not rerun as part of this final commit/PR step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #190
2026-03-23 21:53:19 +00:00
8426741068 feat: add baseline snapshot truth guards (#189)
## Summary
- add explicit BaselineSnapshot lifecycle truth with conservative backfill and a shared truth resolver
- block baseline compare from building, incomplete, or superseded snapshots and align workspace/tenant UI truth surfaces with effective snapshot state
- surface artifact truth separately from operation outcome across baseline profile, snapshot, compare, and operation run pages

## Testing
- integrated browser smoke test on the active feature surfaces
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
- targeted baseline lifecycle and compare guard coverage added in Pest
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4 compliance preserved
- no panel provider registration changes were needed; Laravel 12 providers remain in `bootstrap/providers.php`
- global search remains disabled for the affected baseline resources by design
- destructive actions remain confirmation-gated; capture and compare actions keep their existing authorization and confirmation behavior
- no new panel assets were added; existing deploy flow for `filament:assets` is unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #189
2026-03-23 11:32:00 +00:00
e7c9b4b853 feat: implement governance artifact truth semantics (#188)
## Summary
- add shared governance artifact truth presentation and badge taxonomy
- integrate artifact-truth messaging across baseline, evidence, tenant review, review pack, and operation run surfaces
- add focused regression coverage and spec artifacts for artifact truth semantics

## Testing
- not run in this step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #188
2026-03-23 00:13:57 +00:00
92f39d9749 feat: add shared reason translation contract (#187)
## Summary
- introduce a shared reason-translation contract with envelopes, presenter helpers, fallback handling, and provider translation support
- adopt translated operator-facing reason presentation across operation runs, notifications, provider guidance, tenant operability, and RBAC-related surfaces
- add Spec 157 design artifacts and targeted regression coverage for translation quality, diagnostics retention, and authorization-safe guidance

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php`

## Notes
- Livewire v4.0+ compliance remains unchanged within the existing Filament v5 stack.
- No new panel was added; provider registration remains in `bootstrap/providers.php`.
- No new globally searchable resource was introduced.
- No new destructive action family was introduced.
- No new assets were added; the existing `filament:assets` deployment behavior remains unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #187
2026-03-22 20:19:43 +00:00
3c3daae405 feat: normalize operator outcome taxonomy (#186)
## Summary
- introduce a shared operator outcome taxonomy with semantic axes, severity bands, and next-action policy
- apply the taxonomy to operations, evidence/review completeness, baseline semantics, and restore semantics
- harden badge rendering, tenant-safe filtering/search behavior, and operator-facing summary/notification wording
- add the spec kit artifacts, reference documentation, and regression coverage for diagnostic-vs-primary state handling

## Testing
- focused Pest coverage for taxonomy registry and badge guardrails
- operations presentation and notification tests
- evidence, baseline, restore, and tenant-scope regression tests

## Notes
- Livewire v4.0+ compliance is preserved in the existing Filament v5 stack
- panel provider registration remains unchanged in bootstrap/providers.php
- no new globally searchable resource was added; adopted resources remain tenant-safe and out of global search where required
- no new destructive action family was introduced; existing actions keep their current authorization and confirmation behavior
- no new frontend asset strategy was introduced; existing deploy flow with filament:assets remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #186
2026-03-22 12:13:34 +00:00
a4f2629493 feat: add tenant review layer (#185)
## Summary
- add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export
- extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles
- add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact --filter="TenantReview"`
- `CI=1 vendor/bin/sail artisan test --compact`

## Notes
- Livewire v4+ compliant via existing Filament v5 stack
- panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php`
- `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply
- destructive review actions use action handlers with confirmation and policy enforcement

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #185
2026-03-21 22:03:01 +00:00
b1e1e06861 feat: implement finding risk acceptance lifecycle (#184)
## Summary
- add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support
- add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament
- add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- CI=1 vendor/bin/sail artisan test --compact
- manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility

## Notes
- Filament implementation remains on v5 with Livewire v4-compatible surfaces
- canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php
- finding exceptions stay out of global search in this rollout

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #184
2026-03-20 01:07:55 +00:00
a74ab12f04 feat: implement evidence domain foundation (#183)
## Summary
- add the Evidence Snapshot domain with immutable tenant-scoped snapshots, per-dimension items, queued generation, audit actions, badge mappings, and Filament list/detail surfaces
- add the workspace evidence overview, capability and policy wiring, Livewire update-path hardening, and review-pack integration through explicit evidence snapshot resolution
- add spec 153 artifacts, migrations, factories, and focused Pest coverage for evidence, review-pack reuse, authorization, action-surface regressions, and audit behavior

## Testing
- `vendor/bin/sail artisan test --compact --stop-on-failure`
- `CI=1 vendor/bin/sail artisan test --compact`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- branch: `153-evidence-domain-foundation`
- commit: `b7dfa279`
- spec: `specs/153-evidence-domain-foundation/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #183
2026-03-19 13:32:52 +00:00
5ec62cd117 feat: harden livewire trusted state boundaries (#182)
## Summary
- add the shared trusted-state model and resolver helpers for first-slice Livewire and Filament surfaces
- harden managed tenant onboarding, tenant required permissions, and system runbooks against forged or stale public state
- add focused Pest guard and regression coverage plus the complete spec 152 artifact set

## Validation
- `vendor/bin/sail artisan test --compact`
- manual smoke validated on `/admin/onboarding/{onboardingDraft}`
- manual smoke validated on `/admin/tenants/{tenant}/required-permissions`
- manual smoke validated on `/system/ops/runbooks`

## Notes
- Livewire v4.0+ / Filament v5 stack unchanged
- no new panels, routes, assets, or global-search changes
- provider registration remains in `bootstrap/providers.php`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #182
2026-03-18 23:01:14 +00:00
ec71c2d4e7 feat: harden findings workflow and audit backstop (#181)
## Summary
- harden finding lifecycle changes behind the canonical `FindingWorkflowService` gateway
- route automated resolve and reopen flows through the same audited workflow path
- tighten tenant and workspace scope checks on finding actions and audit visibility
- add focused spec artifacts, workflow regression coverage, automation coverage, and audit visibility tests
- update legacy finding model tests to use the workflow service after direct lifecycle mutators were removed

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- focused findings and audit slices passed during implementation
- `vendor/bin/sail artisan test --compact tests/Feature/Models/FindingResolvedTest.php`
- full repository suite passed: `2757 passed`, `8 skipped`, `14448 assertions`

## Notes
- Livewire v4.0+ compliance preserved
- no new Filament assets or panel providers introduced; provider registration remains in `bootstrap/providers.php`
- findings stay on existing Filament action surfaces, with destructive actions still confirmation-gated
- no global search behavior was changed for findings resources

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #181
2026-03-18 12:57:23 +00:00
1f3619bd16 feat: tenant-owned query canon and wrong-tenant guards (#180)
## Summary
- introduce a shared tenant-owned query and record-resolution canon for first-slice Filament resources
- harden direct views, row actions, bulk actions, relation managers, and workspace-admin canonical viewers against wrong-tenant access
- add registry-backed rollout metadata, search posture handling, architectural guards, and focused Pest coverage for scope parity and 404/403 semantics

## Included
- Spec 150 package under `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/`
- shared support classes: `TenantOwnedModelFamilies`, `TenantOwnedQueryScope`, `TenantOwnedRecordResolver`
- shared Filament concern: `InteractsWithTenantOwnedRecords`
- resource/page/policy hardening across findings, policies, policy versions, backup schedules, backup sets, restore runs, inventory items, and Entra groups
- additional regression coverage for canonical tenant state, wrong-tenant record resolution, relation-manager congruence, and action-surface guardrails

## Validation
- `vendor/bin/sail artisan test --compact` passed
- full suite result: `2733 passed, 8 skipped`
- formatting applied with `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliant via existing Filament v5 stack
- provider registration remains in `bootstrap/providers.php`
- globally searchable first-slice posture: Entra groups scoped; policies and policy versions explicitly disabled
- destructive actions continue to use confirmation and policy authorization
- no new Filament assets added; existing deployment flow remains unchanged, including `php artisan filament:assets` when registered assets are used

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #180
2026-03-18 08:33:13 +00:00
5bcb4f6ab8 feat: harden queued execution legitimacy (#179)
## Summary
- add a canonical queued execution legitimacy contract for actor-bound and system-authority operation runs
- enforce legitimacy before queued jobs transition runs to running across provider, inventory, restore, bulk, sync, and scheduled backup flows
- surface blocked execution outcomes consistently in Monitoring, notifications, audit data, and the tenantless operation viewer
- add Spec 149 artifacts and focused Pest coverage for legitimacy decisions, middleware ordering, blocked presentation, retry behavior, and cross-family adoption

## Testing
- vendor/bin/sail artisan test --compact tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionMiddlewareOrderingTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/ExecuteRestoreRunExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/BulkOperationExecutionReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionRetryReauthorizationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionContractMatrixTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/QueuedExecutionAuditTrailTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

## Manual validation
- validated queued provider execution blocking for tenant operability drift in the integrated browser on /admin/operations and /admin/operations/{run}
- validated 404 vs 403 route behavior for non-membership vs in-scope capability denial
- validated initiator-null blocked system-run behavior without creating a user terminal notification

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #179
2026-03-17 21:52:40 +00:00
ede4cc363d docs: add domain expansion roadmap entries and spec candidates (#178)
## Summary

Adds roadmap-level entries and qualified spec candidates for four missing high-value domain expansions, aligning product docs with already-discussed platform coverage direction.

### New Roadmap Entries (Mid-term)

- **Entra Role Governance** — identity administration posture, role definition/assignment visibility
- **SharePoint Tenant-Level Sharing Governance** — tenant-wide sharing/external access posture
- **Enterprise App / Service Principal Governance** — privileged permissions, expiring credentials, review workflows
- **Security Posture Signals** — Defender VM exposure, backup assurance, evidence inputs for reviews

### New Spec Candidates (Qualified)

| Candidate | Priority |
|-----------|----------|
| Enterprise App / Service Principal Governance | high |
| SharePoint Tenant-Level Sharing Governance | medium |
| Entra Role Governance | medium |
| Security Posture Signals Foundation | medium |

### What this does NOT change

- No strategy/domain-coverage doc changes
- No existing roadmap structure rewrite
- No existing candidate duplication
- No implementation specs or code changes

### Files modified

- `docs/product/roadmap.md`
- `docs/product/spec-candidates.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #178
2026-03-17 12:18:37 +00:00
417df4f9aa feat: central tenant operability policy (#177)
## Summary
- centralize tenant operability into a lane-aware, actor-aware policy boundary
- align selector eligibility, administrative discoverability, remembered context, tenant-bound routes, and canonical run viewers
- add focused Pest coverage plus Spec 148 artifacts and final polish task completion

## Validation
- `vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantOperabilityServiceTest.php tests/Unit/Tenants/TenantOperabilityOutcomeTest.php tests/Feature/Workspaces/ChooseTenantPageTest.php tests/Feature/Workspaces/SelectTenantControllerTest.php tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php tests/Feature/Rbac/TenantResourceAuthorizationTest.php tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`
- manual browser smoke checks on `/admin/choose-tenant`, `/admin/tenants`, `/admin/onboarding`, `/admin/onboarding/{draft}`, and `/admin/operations/{run}`

## Filament / platform notes
- Livewire v4 compliance preserved
- panel provider registration unchanged in `bootstrap/providers.php`
- Tenant resource global search remains backed by existing view/edit pages and is now separated from active-only selector eligibility
- destructive actions remain action closures with confirmation and authorization enforcement
- no asset pipeline changes and no new `filament:assets` deployment requirement

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #177
2026-03-17 11:48:55 +00:00
73a879d061 feat: implement spec 147 tenant context enforcement (#176)
## Summary
- implement Spec 147 for workspace-first tenant selector and remembered tenant context enforcement
- harden canonical and tenant-bound route behavior so selected tenant mismatch stays informational
- fix drift finding subject fallback for workspace-safe RBAC identifiers and centralize finding subject resolution

## Testing
- vendor/bin/sail artisan test --compact tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingsListDefaultsTest.php
- vendor/bin/sail bin pint --dirty --format agent

## Notes
- branch pushed at de0679cd8b
- includes the spec artifacts under specs/147-tenant-selector-remembered-context-enforcement/

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #176
2026-03-16 22:52:58 +00:00
6ca496233b feat: centralize tenant lifecycle presentation (#175)
## Summary
- add a shared tenant lifecycle presentation contract and referenced-tenant adapter for canonical lifecycle labels and helper copy
- align tenant, chooser, onboarding, archived-banner, and tenantless operation viewer surfaces with the shared lifecycle vocabulary
- add Spec 146 design artifacts, audit notes, and regression coverage for lifecycle presentation across Filament and onboarding surfaces

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Badges/TenantStatusBadgeTest.php tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Tenants/TenantLifecycleTest.php tests/Unit/Support/Tenants/TenantLifecyclePresentationTest.php tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php tests/Feature/Onboarding/TenantLifecyclePresentationCopyTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`

## Notes
- Livewire v4.0+ compliance preserved; this change is presentation-only on existing Filament v5 surfaces.
- Panel provider registration remains unchanged in `bootstrap/providers.php`.
- No global-search behavior changed; no resource was newly made globally searchable or disabled.
- No destructive actions were added or changed.
- No asset registration strategy changed; existing deploy flow for `php artisan filament:assets` remains unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #175
2026-03-16 18:18:53 +00:00
440e63edff feat: implement tenant action taxonomy lifecycle visibility (#174)
## Summary

Implements Spec 145 for tenant action taxonomy and lifecycle-safe visibility.

This PR:
- adds a central tenant action policy surface and supporting value objects
- aligns tenant list, detail, edit, onboarding, and widget surfaces around lifecycle-safe actions
- standardizes operator-facing lifecycle wording around View, Resume onboarding, Archive, Restore, and Complete onboarding
- tightens onboarding and tenant lifecycle authorization semantics, including honest 404 vs 403 behavior
- updates related regression coverage and spec artifacts for Spec 145
- fixes follow-on full-suite regressions uncovered during validation, including onboarding browser flows, provider consent fixtures, workspace redirect DI expectations, and critical table/action/UI expectation drift

## Validation

Executed and passed:
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact

Result:
- 2581 passed
- 8 skipped
- 13534 assertions

## Notes

- Base branch: dev
- Feature branch commit: a33a41b
- Filament v5 / Livewire v4 compliance preserved
- No panel provider registration changes; Laravel 12 provider registration remains in bootstrap/providers.php
- No new globally searchable resource behavior added in this slice
- Destructive lifecycle actions remain confirmation-gated and authorization-protected

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #174
2026-03-16 00:57:17 +00:00
b0a724acef feat: harden canonical run viewer and onboarding draft state (#173)
## Summary
- harden the canonical operation run viewer so mismatched, missing, archived, onboarding, and selector-excluded tenant context no longer invalidates authorized canonical run viewing
- extend canonical route, header-context, deep-link, and presentation coverage for Spec 144 and add the full spec artifact set under `specs/144-canonical-operation-viewer-context-decoupling/`
- harden onboarding draft provider-connection resume logic so stale persisted provider connections fall back to the connect-provider step instead of resuming invalid state
- add architecture-audit follow-up candidate material and prompt assets for the next governance hardening wave

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Monitoring/HeaderContextBarTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php`

## Notes
- branch: `144-canonical-operation-viewer-context-decoupling`
- base: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #173
2026-03-15 18:32:04 +00:00
641bb4afde feat: implement tenant lifecycle operability semantics (#172)
## Summary
- implement Spec 143 tenant lifecycle, operability, and tenant-context semantics across chooser, tenant management, onboarding, and canonical operation viewers
- add centralized tenant lifecycle and operability support types, audit action coverage, and lifecycle-aware badge and action handling
- add feature and unit coverage for tenant chooser eligibility, global search scoping, canonical operation access, onboarding authorization, and lifecycle presentation

## Testing
- vendor/bin/sail artisan test --compact
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #172
2026-03-15 09:08:36 +00:00
3f6f80f7af feat: refine onboarding draft flow and RBAC diff UX (#171)
## Summary
- add the RBAC role definition diff UX upgrade as the first concrete consumer of the shared diff presentation foundation
- refine managed tenant onboarding draft routing, CTA labeling, and cancellation redirect behavior
- tighten related Filament and diff rendering regression coverage

## Testing
- updated focused Pest coverage for onboarding draft routing and lifecycle behavior
- updated focused Pest coverage for shared diff partials and RBAC finding rendering

## Notes
- Livewire v4.0+ compliance is preserved within the existing Filament v5 surfaces
- provider registration remains unchanged in bootstrap/providers.php
- no new Filament assets were added; existing deployment practice still relies on php artisan filament:assets when assets change

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #171
2026-03-14 20:09:54 +00:00
0b5cadc234 feat: add shared diff presentation foundation (#170)
## Summary
- add a shared diff presentation layer under `app/Support/Diff` with deterministic row classification, summary derivation, and value stringification
- centralize diff-state badge semantics through `BadgeCatalog` with a dedicated `DiffRowStatusBadge`
- add reusable Filament diff partials, focused Pest coverage, and the full SpecKit artifact set for spec 141

## Testing
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Diff/DiffRowStatusTest.php tests/Unit/Support/Diff/DiffRowTest.php tests/Unit/Support/Diff/DiffPresenterTest.php tests/Unit/Support/Diff/ValueStringifierTest.php tests/Unit/Badges/DiffRowStatusBadgeTest.php tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php tests/Feature/Support/Diff/SharedDiffRowPartialTest.php tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Filament / Livewire Contract
- Livewire v4.0+ compliance: unchanged and respected; this feature adds presentation support only within the existing Filament v5 / Livewire v4 stack
- Provider registration: unchanged; no panel/provider changes were required, so `bootstrap/providers.php` remains the correct registration location
- Global search: unchanged; no Resource or global-search behavior was added or modified
- Destructive actions: none introduced in this feature
- Asset strategy: no new registered Filament assets; shared Blade partials rely on the existing asset pipeline and standard deploy step for `php artisan filament:assets` when assets change generally
- Testing coverage: presenter, DTOs, stringifier, badge semantics, summary partial, row partial, and inline-list partial are covered by focused Pest unit and feature tests

## Notes
- Spec checklist status is complete for `specs/141-shared-diff-presentation-foundation/checklists/requirements.md`
- This PR preserves specialized diff renderers and documents incremental adoption rather than forcing migration in the same change

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #170
2026-03-14 12:32:08 +00:00
d2f2c55ead feat: add onboarding lifecycle checkpoints and locking (#169)
## Summary
- add canonical onboarding lifecycle and checkpoint fields plus optimistic locking versioning for managed tenant onboarding drafts
- introduce centralized onboarding lifecycle and mutation services and route wizard mutations through version-checked writes
- convert Verify Access and Bootstrap into live checkpoint-driven wizard states with conditional polling and updated browser/feature/unit coverage
- add Spec Kit artifacts for feature 140, including spec, plan, tasks, research, data model, quickstart, checklist, and logical contract

## Validation
- branch was committed and pushed cleanly
- focused tests and formatting were updated during implementation work
- full validation was not re-run as part of this final git/PR step

## Notes
- base branch: `dev`
- feature branch: `140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp`
- outstanding follow-up items, if any, remain tracked in `specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/tasks.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #169
2026-03-14 11:02:29 +00:00
b182f55562 feat: add verify access required permissions assist (#168)
## Summary
- add an in-place Required Permissions assist to the onboarding Verify Access step via a Filament slideover
- route permission-related verification remediation links into the assist first and keep deep-dive links opening in a new tab
- add view-model and link-behavior helpers plus focused feature, browser, RBAC, and unit coverage for the new assist

## Scope
- onboarding wizard Verify Access UX
- Required Permissions assist rendering and link behavior
- Spec 139 artifacts, contracts, and checklist updates

## Notes
- branch: `139-verify-access-permissions-assist`
- commit: `b4193f1`
- worktree was clean at PR creation time

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #168
2026-03-14 02:00:28 +00:00
98e2b5acd9 feat: managed tenant onboarding draft identity and resume semantics (#167)
## Summary
- add canonical managed-tenant onboarding draft routing with explicit draft identity and landing vs concrete draft behavior
- implement draft lifecycle, authorization, attribution, picker UX, resume-stage resolution, and auditable cancel or completion semantics
- add focused feature, unit, and browser coverage plus Spec 138 artifacts for the onboarding draft resume flow

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/OnboardingDraftAuditTest.php tests/Feature/Onboarding/OnboardingDraftAccessTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftRoutingTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php tests/Feature/Onboarding/OnboardingVerificationClustersTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Unit/Onboarding tests/Unit/VerificationReportSanitizerEvidenceKindsTest.php tests/Browser/OnboardingDraftRefreshTest.php tests/Browser/OnboardingDraftVerificationResumeTest.php`
- passed: 69 tests, 251 assertions

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #167
2026-03-13 23:45:23 +00:00
bab01f07a9 feat: standardize platform provider identity (#166)
## Summary
- standardize Microsoft provider connections around explicit platform vs dedicated identity modes
- centralize admin-consent URL and runtime identity resolution so platform flows no longer fall back to tenant-local credentials
- add migration classification, richer consent and verification state handling, dedicated override management, and focused regression coverage

## Validation
- focused repo test coverage was added across provider identity, onboarding, audit, policy, guard, and migration flows
- latest explicit passing run in the workspace: `vendor/bin/sail artisan test --compact tests/Feature/AdminConsentCallbackTest.php tests/Feature/Audit/ProviderConnectionConsentAuditTest.php`

## Notes
- branch includes the full Spec 137 artifact set under `specs/137-platform-provider-identity/`
- target base branch: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #166
2026-03-13 16:29:08 +00:00
45a804970e feat: complete admin canonical tenant rollout (#165)
## Summary
- complete Spec 136 canonical admin tenant rollout across admin-visible and shared Filament surfaces
- add the shared panel-aware tenant resolver helper, persisted filter-state synchronization, and admin navigation segregation for tenant-sensitive resources
- expand regression, guard, and parity coverage for admin-path tenant resolution, stale filters, workspace-wide tenant-default surfaces, and panel split behavior

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php`
- `vendor/bin/sail artisan test --compact --filter='CanonicalAdminTenantFilterState|PolicyResource|BackupSchedule|BackupSet|FindingResource|BaselineCompareLanding|RestoreRunResource|InventoryItemResource|PolicyVersionResource|ProviderConnectionResource|TenantDiagnostics|InventoryCoverage|InventoryKpiHeader|AuditLog|EntraGroup'`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliance is preserved with Filament v5.
- Provider registration remains unchanged in `bootstrap/providers.php`.
- `PolicyResource` and `PolicyVersionResource` have admin global search disabled explicitly; `EntraGroupResource` keeps admin-aware scoped search with a View page.
- Destructive and governance-sensitive actions retain existing confirmation and authorization behavior while using canonical tenant parity.
- No new assets were introduced, so deployment asset strategy is unchanged and does not add new `filament:assets` work.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #165
2026-03-13 08:09:20 +00:00
cc93329672 feat: canonical tenant context resolution (#164)
## Summary
- introduce a canonical admin tenant filter-state helper and route all in-scope workspace-admin tenant resolution through `OperateHubShell::activeEntitledTenant()`
- align operations monitoring, operation-run deep links, Entra group admin list/view/search behavior, and shared context-bar rendering with the documented scope contract
- add the Spec 135 design artifacts, architecture note, focused guardrail coverage, and non-regression tests for filter persistence, direct-record access, and global search safety

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php tests/Feature/Spec085/OperationsIndexHeaderTest.php tests/Feature/Spec085/RunDetailBackAffordanceTest.php tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/EntraGroupAdminScopeTest.php tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php tests/Feature/DirectoryGroups/BrowseGroupsTest.php tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php tests/Feature/Guards/AdminTenantResolverGuardTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php tests/Feature/Filament/TenantScopingTest.php tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php`

## Notes
- Filament v5 remains on Livewire v4.0+ compliant surfaces only.
- No provider registration changes were needed; Laravel 12 provider registration remains in `bootstrap/providers.php`.
- Entra group global search remains enabled and is now scoped to the canonical admin tenant contract.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #164
2026-03-11 21:24:28 +00:00
28cfe38ba4 feat: lay audit log foundation (#163)
## Summary
- turn the Monitoring audit log placeholder into a real workspace-scoped audit review surface
- introduce a shared audit recorder, richer audit value objects, and additive audit log schema evolution
- add audit outcome and actor badges, permission-aware related navigation, and durable audit retention coverage

## Included
- canonical `/admin/audit-log` list and detail inspection UI
- audit model helpers, taxonomy expansion, actor/target snapshots, and recorder/builder services
- operation terminal audit writes and purge command retention changes
- spec 134 design artifacts and focused Pest coverage for audit foundation behavior

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Audit tests/Unit/Badges/AuditBadgesTest.php tests/Feature/Filament/AuditLogPageTest.php tests/Feature/Filament/AuditLogDetailInspectionTest.php tests/Feature/Filament/AuditLogAuthorizationTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php`

## Notes
- Livewire v4.0+ compliance is preserved within the existing Filament v5 application.
- No provider registration changes were needed; panel provider registration remains in `bootstrap/providers.php`.
- No new globally searchable resource was introduced.
- The audit page remains read-only; no destructive actions were added.
- No new asset pipeline changes were introduced; existing deploy-time `php artisan filament:assets` behavior remains unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #163
2026-03-11 09:39:37 +00:00
d4fb886de0 feat: standardize enterprise detail pages (#162)
## Summary
- introduce a shared enterprise-detail composition layer for Filament detail pages
- migrate BackupSet, BaselineSnapshot, EntraGroup, and OperationRun detail screens to the shared summary-first layout
- add regression and unit coverage for section hierarchy, related context, degraded states, and duplicate fact/badge presentation

## Scope
- adds shared support classes under `app/Support/Ui/EnterpriseDetail`
- adds shared enterprise detail Blade partials under `resources/views/filament/infolists/entries/enterprise-detail`
- updates touched Filament resources/pages to use the shared detail shell
- includes Spec 133 artifacts under `specs/133-detail-page-template`

## Notes
- branch: `133-detail-page-template`
- base: `dev`
- commit: `fd294c7`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #162
2026-03-10 23:06:26 +00:00
8ee1174c8d feat: add resolved reference presentation layer (#161)
## Summary
- add the shared resolved-reference foundation with registry, resolvers, presenters, and badge semantics
- refactor related context, assignment evidence, and policy-version assignment rendering toward label-first reference presentation
- add Spec 132 artifacts and focused Pest coverage for reference resolution, degraded states, canonical linking, and tenant-context carryover

## Verification
- `vendor/bin/sail bin pint --dirty --format agent`
- focused Pest verification was marked complete in the task artifact

## Notes
- this PR is opened from the current session branch
- `specs/132-guid-context-resolver/tasks.md` reflects in-progress completion state for the implemented tasks

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #161
2026-03-10 18:52:52 +00:00
b15d1950b4 feat: add cross-resource navigation cohesion (#160)
## Summary
- add a shared cross-resource navigation layer with canonical navigation context and related-context rendering
- wire findings, policy versions, baseline snapshots, backup sets, and canonical operations surfaces into consistent drill-down flows
- extend focused Pest coverage for canonical operations links, related navigation, and tenant-context preservation

## Testing
- focused Pest coverage for spec 131 was added and the task list marks the implementation verification and Pint steps as completed

## Follow-up
- manual QA checklist item `T036` in `specs/131-cross-resource-navigation/tasks.md` is still open and should be completed during review

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #160
2026-03-10 16:08:14 +00:00
4b3113498c docs: amend constitution to v1.11.0 with UI naming standards (#159)
## Summary
- bump the TenantPilot constitution to v1.11.0
- add the operator-facing UI naming standard as `UI-NAMING-001`
- propagate the naming rule into the Spec Kit plan/spec/tasks templates

## Scope
This PR includes only the constitution and Spec Kit template updates needed to enforce operator-facing naming consistency.

## Details
- primary actions standardize on `Verb + Object`
- scope terms such as `Workspace` and `Tenant` are not used as primary action labels
- source/domain wording stays secondary unless disambiguation is required
- run labels, notifications, and audit prose align to the same domain vocabulary

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #159
2026-03-10 14:23:09 +00:00
3c445709af feat: add structured baseline snapshot rendering (#158)
## Summary
- replace the baseline snapshot detail page with a structured summary-first rendering flow
- add a presenter plus renderer registry with RBAC, compliance, and fallback renderers
- add grouped policy-type browsing, fidelity and gap badges, and workspace authorization coverage
- add Feature 130 spec, plan, contract, research, quickstart, and completed task artifacts

## Testing
- focused Pest coverage was added for structured rendering, fallback behavior, degraded states, authorization, presenter logic, renderer resolution, and badge mapping
- I did not rerun the full validation suite in this final PR step

## Notes
- base branch: `dev`
- feature branch: `130-structured-snapshot-rendering`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #158
2026-03-10 08:28:06 +00:00
0c709df54e Spec 129: add workspace admin home overview (#157)
## Summary
- make `/admin` the canonical workspace-level home instead of implicitly forcing tenant context
- add a new Filament workspace overview page with bounded workspace-safe widgets, quick actions, and empty states
- align panel routing, middleware, redirect helpers, and tests with the new workspace-home semantics
- add Spec 129 design artifacts, contracts, and focused Pest coverage for landing, navigation, content, operations, and authorization

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php tests/Feature/Filament/WorkspaceOverviewLandingTest.php tests/Feature/Filament/WorkspaceOverviewNavigationTest.php tests/Feature/Filament/WorkspaceOverviewContentTest.php tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliance is preserved through Filament v5 usage.
- Panel provider registration remains in `bootstrap/providers.php` for Laravel 12.
- This feature adds a workspace overview page for the admin panel home; it does not introduce destructive actions.
- No new Filament assets were added, so there is no additional `filament:assets` deployment requirement for this branch.
- Manual browser QA for the quickstart scenarios was not completed in this session because the local browser opened at the Microsoft login flow without an authenticated test session.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #157
2026-03-09 21:53:25 +00:00
ef41c9193a feat: add Intune RBAC baseline compare support (#156)
## Summary
- add Intune RBAC Role Definition baseline scope support, capture references, compare classification, findings evidence, and landing/detail UI labels
- keep Intune Role Assignments explicitly excluded from baseline compare scope, summaries, findings, and restore messaging
- add focused Pest coverage for baseline scope selection, capture, compare behavior, recurrence, isolation, findings rendering, inventory anchoring, and RBAC summaries

## Verification
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php tests/Unit/Baselines/BaselineScopeTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Filament/BaselineProfileFoundationScopeTest.php tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/DriftStaleAutoResolveTest.php tests/Feature/Inventory/InventorySyncButtonTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
- result: `71 passed (467 assertions)`

## Filament / Platform Notes
- Livewire compliance: unchanged and compatible with Livewire v4.0+
- Provider registration: no panel/provider changes; `bootstrap/providers.php` remains the registration location
- Global search: no new globally searchable resource added; existing global search behavior is unchanged
- Destructive actions: no new destructive actions introduced; existing confirmed actions remain unchanged
- Assets: no new Filament assets introduced; deploy asset handling remains unchanged, including `php artisan filament:assets`
- Testing plan covered: baseline profile scope, snapshot detail, compare job, findings recurrence, findings detail, compare landing labels, inventory sync anchoring, and tenant isolation

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #156
2026-03-09 18:49:20 +00:00
c6e7591d19 feat: add Intune RBAC inventory and backup support (#155)
## Summary
- add Intune RBAC role definitions and role assignments as foundation-backed inventory, backup, and versioned snapshot types
- add RBAC-specific normalization, coverage, permission-warning handling, and preview-only restore safety behavior across existing Filament and service surfaces
- add spec 127 artifacts, contracts, audits, and focused regression coverage for inventory, backup, versioning, verification, and authorization behavior

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Filament/InventoryCoverageTableTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php tests/Unit/GraphContractRegistryTest.php tests/Unit/FoundationSnapshotServiceTest.php tests/Feature/Verification/IntuneRbacPermissionCoverageTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Unit/IntuneRoleAssignmentNormalizerTest.php`

## Notes
- tasks in `specs/127-rbac-inventory-backup/tasks.md` are complete except `T041`, which is the documented manual QA validation step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #155
2026-03-09 10:40:51 +00:00
a490261eca feat: standardize filter UX across key resources (#154)
## Summary
- standardize filter UX across key Filament resources with shared thin filter helpers for centralized option sourcing and archived/date-range presets
- add persistence, essential filters, and OperationCatalog-aligned labels across the targeted resource tables
- add and extend focused Pest coverage for guards, persistence, filter behavior, scope safety, and the new Spec 126 planning artifacts

## Spec 126
- add the full Spec 126 artifact set under `specs/126-filter-ux-standardization/`
- align spec, plan, research, data model, quickstart, contract, checklist, and tasks for implementation readiness

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Feature/Filament/TableStatePersistenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Findings/FindingsListDefaultsTest.php tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/PolicyVersionListFiltersTest.php tests/Feature/Filament/RestoreRunListFiltersTest.php tests/Feature/Filament/InventoryItemListFiltersTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php tests/Feature/ProviderConnections/TenantFilterOverrideTest.php tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php tests/Feature/Filament/BaselineTenantAssignmentsRelationManagerTest.php`

## Notes
- no new OperationRun lifecycle or operational workflow behavior is introduced; only existing OperationRun table filter-label alignment and related coverage are in scope
- existing authorization and action-surface semantics remain unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #154
2026-03-09 06:27:22 +00:00
02028be7e4 docs: add canonical filament UI standards (#153)
## Summary
- add canonical UI standards under `docs/product/standards/`
- add a comprehensive filter audit as source material for future filter standardization work
- extend the constitution with incremental UI standards enforcement guidance

## Included
- `docs/product/standards/README.md`
- `docs/product/standards/filament-table-ux.md`
- `docs/product/standards/filament-filter-ux.md`
- `docs/product/standards/filament-actions-ux.md`
- `docs/product/standards/list-surface-review-checklist.md`
- `docs/audits/filter-audit-comprehensive.md`
- `.specify/memory/constitution.md`

## Notes
- this is documentation and governance work only; no runtime code paths changed
- no tests were run because the change is docs-only
- the new standards structure separates permanent principles, living standards, rollout audits, and review checklists

## Review Focus
- confirm the standards location under `docs/product/standards/`
- confirm the constitution principle belongs at the constitutional level
- confirm the filter audit should live under `docs/audits/` as reference material

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #153
2026-03-08 23:17:37 +00:00
a4f5c4f122 Spec 125: standardize Filament table UX (#152)
## Summary
- standardize Filament table defaults across resources, relation managers, widgets, custom pages, and picker tables
- add shared pagination profiles, calm default column visibility, explicit empty states, and session persistence on designated critical resource lists
- complete Spec 125 artifacts, regression tests, and dashboard widget follow-up for lazy loading, sortable columns, and toggleable detail columns

## Verification
- `docker exec tenantatlas-laravel.test-1 php artisan test --compact --filter=BaselineCompareNow`
- `docker exec tenantatlas-laravel.test-1 php artisan test --compact --filter=TableStandardsBaseline`
- `docker exec tenantatlas-laravel.test-1 php artisan test --compact --filter=TableDetailVisibility`
- `docker exec tenantatlas-laravel.test-1 php artisan test --compact --filter=FilamentTableRiskExceptions`
- full suite run completed: `2017 passed, 10 failed, 8 skipped`
- manual browser QA completed on the tenant dashboard for lazy loading, sortable widget columns, toggleable hidden status columns, badges, and pagination

## Known Failures
The full suite still has 10 pre-existing failures unrelated to this branch:
- `Tests\\Unit\\OpsUx\\SummaryCountsNormalizerTest`
- `Tests\\Feature\\BackupWithAssignmentsConsistencyTest` (2 tests)
- `Tests\\Feature\\BaselineDriftEngine\\CaptureBaselineContentTest`
- `Tests\\Feature\\BaselineDriftEngine\\CompareContentEvidenceTest`
- `Tests\\Feature\\BaselineDriftEngine\\ResolverTest`
- `Tests\\Feature\\Filament\\TenantDashboardDbOnlyTest`
- `Tests\\Feature\\Operations\\ReconcileAdapterRunsJobTrackingTest`
- `Tests\\Feature\\ReviewPack\\ReviewPackRbacTest`
- `Tests\\Feature\\Verification\\VerificationReportRedactionTest`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #152
2026-03-08 22:54:56 +00:00
3971c315d8 feat: add inventory coverage interactive table (#151)
## Summary
- replace the static Inventory Coverage HTML tables with a Filament native searchable, sortable, filterable table on the existing tenant page
- normalize supported policy types and foundations into one runtime dataset while preserving centralized badge semantics and the documented read-only action-surface exemption
- add the full spec kit artifact set for feature 124 and focused Pest coverage for rendering, search, sort, filters, empty state, and regression-sensitive page copy

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php tests/Feature/Filament/InventoryPagesTest.php tests/Feature/Filament/InventoryHubDbOnlyTest.php`

## Filament Notes
- Livewire v4.0+ compliance: yes, this uses Filament v5 table APIs on the existing page and does not introduce any Livewire v3 patterns
- Provider registration: unchanged; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- Globally searchable resources: none changed in this feature; no Resource global-search behavior was added or modified
- Destructive actions: none; the page remains read-only and only exposes a non-destructive clear-filters empty-state action
- Asset strategy: no new panel or shared assets were added, so no `filament:assets` deployment change is required for this feature
- Testing plan delivered: focused Filament/Pest coverage for the page table surface plus existing page-load regressions

## Follow-up
- Manual dark-mode and badge-regression QA from task `T018` is still pending and should be completed before merge if that check remains mandatory in your review flow.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #151
2026-03-08 18:33:00 +00:00
bfc483e9b8 Docs: remove monitoring hub candidate (#150)
## Summary
- remove the `Monitoring hub calmer polling / less UI noise` item from the spec candidates inbox

## Notes
- this was the only remaining local change after branch cleanup
- direct push to `dev` was blocked by branch protection, so this change is proposed via PR instead

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #150
2026-03-08 11:28:44 +00:00
4db73e872e Spec 123: operations auto-refresh pass (#149)
## Summary
- add conditional polling support for the tenantless operation run viewer and tenant review pack card
- add focused Pest coverage for active vs terminal polling behavior and related review pack access regressions
- add the full Spec Kit artifacts for Spec 123, including spec, plan, research, data model, contracts, quickstart, tasks, and checklist

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/RunDetailPollingStopsOnTerminalTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Manual QA task `T014` in the Spec Kit checklist remains to be completed outside this automated flow.
- Livewire v4.0+ compliance is unchanged.
- No panel provider changes were made; provider registration remains in `bootstrap/providers.php`.
- No global-search behavior was changed.
- No destructive actions were added or modified.
- No new Filament assets were introduced; existing deployment expectations for `php artisan filament:assets` remain unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #149
2026-03-08 11:11:26 +00:00
73a3a62451 Spec 122: Empty state consistency pass (#148)
## Summary
- unify empty-state UX across the six in-scope Filament list pages
- move empty-state ownership toward resource `table()` definitions while preserving existing RBAC behavior
- add focused Pest coverage for empty-state rendering, CTA outcomes, populated-state regression behavior, and action-surface compliance
- add the Spec 122 planning artifacts and product discovery documents used for this pass

## Changed surfaces
- `PolicyResource`
- `BackupSetResource`
- `RestoreRunResource`
- `BackupScheduleResource`
- `WorkspaceResource`
- `AlertDeliveryResource`

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/EmptyStateConsistencyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/CreateCtaPlacementTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/PolicySyncStartSurfaceTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetUiEnforcementTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Filament v5 / Livewire v4.0+ compliance is preserved.
- Panel provider registration remains unchanged in `bootstrap/providers.php`.
- No new globally searchable resources were added.
- Destructive actions were not introduced by this pass.
- Alert Deliveries is documented as the explicit no-header-action exemption for the empty-state CTA relocation rule.
- Manual light/dark visual QA evidence is still expected in the PR/review artifact set for the remaining checklist items (`T018`, `T025`).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #148
2026-03-08 02:17:51 +00:00
891f177311 fix: route workspace switch to chooser (#147)
## Summary
- route the context-bar `Switch workspace` link to the canonical chooser flow instead of workspace management
- add focused regression coverage for topbar switching, management separation, and chooser redirect semantics
- add Spec 121 artifacts (`spec`, `plan`, `research`, `data-model`, `contracts`, `quickstart`, `tasks`, checklist)

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Monitoring/HeaderContextBarTest.php tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php tests/Feature/Workspaces/ChooseWorkspacePageTest.php tests/Feature/Workspaces/WorkspaceNavigationHubTest.php tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- base branch: `dev`
- branch: `121-workspace-switch-fix`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #147
2026-03-08 00:58:51 +00:00
cd811cff4f Spec 120: harden secret redaction integrity (#146)
## Summary
- replace broad substring-based masking with a shared exact/path-based secret classifier and workspace-scoped fingerprint hashing
- persist protected snapshot metadata on `policy_versions` and keep secret-only changes visible in compare, drift, restore, review, verification, and ops surfaces
- add Spec 120 artifacts, audit documentation, and focused Pest regression coverage for snapshot, audit, verification, review-pack, and notification behavior

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Intune/PolicySnapshotRedactionTest.php tests/Feature/Intune/PolicySnapshotFingerprintIsolationTest.php tests/Feature/ReviewPack/ReviewPackRedactionIntegrityTest.php tests/Feature/OpsUx/OperationRunNotificationRedactionTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`

## Spec / checklist status
| Checklist | Total | Completed | Incomplete | Status |
|-----------|-------|-----------|------------|--------|
| requirements.md | 16 | 16 | 0 | ✓ PASS |

- `tasks.md`: T001-T032 complete
- `tasks.md`: T033 manual quickstart validation is still open and noted for follow-up

## Filament / platform notes
- Livewire v4 compliance is unchanged
- no panel provider changes; `bootstrap/providers.php` remains the registration location
- no new globally searchable resources were introduced, so global search requirements are unchanged
- no new destructive Filament actions were added
- no new Filament assets were added; no `filament:assets` deployment change is required

## Testing coverage touched
- snapshot persistence and fingerprint isolation
- compare/drift protected-change evidence
- audit, verification, review-pack, ops-failure, and notification sanitization
- viewer/read-only Filament presentation updates

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #146
2026-03-07 16:43:01 +00:00
da1adbdeb5 Spec 119: Drift cutover to Baseline Compare (golden master) (#144)
Implements Spec 119 (Drift Golden Master Cutover):

- Baseline Compare is the only drift writer (`source = baseline.compare`).
- Drift findings now store diff-compatible `evidence_jsonb` (summary.kind, baseline/current policy_version_id refs, fidelity + provenance).
- Findings UI renders one-sided diffs for `missing_policy`/`unexpected_policy` when a single ref exists; otherwise shows explicit “diff unavailable”.
- Removes legacy drift generator runtime (jobs/services/UI) and related tests.
- Adds one-time migration to delete legacy drift findings (`finding_type=drift` where source is null or != baseline.compare).
- Scopes baseline capture & landing duplicate warnings to latest completed inventory sync.
- Canonicalizes compliance `scheduledActionsForRule` drift signal and keeps legacy snapshots comparable.

Tests:
- `vendor/bin/sail artisan test --compact` (full suite per tasks)
- Focused pack: BaselinePolicyVersionResolverTest, BaselineCompareDriftEvidenceContractTest, DriftFindingDiffUnavailableTest, LegacyDriftFindingsCleanupMigrationTest, ComplianceNoncomplianceActionsDriftTest

Notes:
- Livewire v4+ / Filament v5 compatible (no legacy APIs).
- No new external dependencies.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #144
2026-03-06 14:30:49 +00:00
92704a2f7e Spec 118: Resumable baseline evidence capture + snapshot UX (#143)
Implements Spec 118 baseline drift engine improvements:

- Resumable, budget-aware evidence capture for baseline capture/compare runs (resume token + UI action)
- “Why no findings?” reason-code driven explanations and richer run context panels
- Baseline Snapshot resource (list/detail) with fidelity visibility
- Retention command + schedule for pruning baseline-purpose PolicyVersions
- i18n strings for Baseline Compare landing

Verification:
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact --filter=Baseline` (159 passed)

Note:
- `docs/audits/redaction-audit-2026-03-04.md` left untracked (not part of PR).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #143
2026-03-04 22:34:13 +00:00
f08924525d Spec 117: Baseline Drift Engine + evidence fidelity/provenance (#142)
Implements Spec 117 (Golden Master Baseline Drift Engine):

- Adds provider-chain resolver for current state hashes (content evidence via PolicyVersion, meta evidence via inventory)
- Updates baseline capture + compare jobs to use resolver and persist provenance + fidelity
- Adds evidence_fidelity column/index + Filament UI badge/filter/provenance display for findings
- Adds performance guard test + integration tests for drift, fidelity semantics, provenance, filter behavior
- UX fix: Policies list shows "Sync from Intune" header action only when records exist; empty-state CTA remains and is functional

Tests:
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicySyncCtaPlacementTest.php`
- `vendor/bin/sail artisan test --compact --filter=Baseline`

Checklist:
- specs/117-baseline-drift-engine/checklists/requirements.md ✓

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #142
2026-03-03 07:23:01 +00:00
7620144ab6 Spec 116: Baseline drift engine v1 (meta fidelity + coverage guard) (#141)
Implements Spec 116 baseline drift engine v1 (meta fidelity) with coverage guard, stable finding identity, and Filament UI surfaces.

Highlights
- Baseline capture/compare jobs and supporting services (meta contract hashing via InventoryMetaContract + DriftHasher)
- Coverage proof parsing + compare partial outcome behavior
- Filament pages/resources/widgets for baseline compare + drift landing improvements
- Pest tests for capture/compare/coverage guard and UI start surfaces
- Research report: docs/research/golden-master-baseline-drift-deep-analysis.md

Validation
- `vendor/bin/sail bin pint --dirty`
- `vendor/bin/sail artisan test --compact --filter="Baseline"`

Notes
- No destructive user actions added; compare/capture remain queued jobs.
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php for panel providers; not touched here).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #141
2026-03-02 22:02:58 +00:00
fdfb781144 feat(115): baseline operability + alerts (#140)
Implements Spec 115 (Baseline Operability & Alert Integration).

Key changes
- Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares)
- Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics
- Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle
- Baseline Compare UX: shared stats layer + landing/widget consistency

Notes
- Livewire v4 / Filament v5 compatible.
- Destructive-like actions require confirmation (no new destructive actions added here).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #140
2026-03-01 02:26:47 +00:00
0cf612826f feat(114): system console control tower (merged) (#139)
Feature branch PR for Spec 114.

This branch contains the merged agent session work (see merge commit on branch).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #139
2026-02-28 00:15:31 +00:00
200498fa8e feat(113): Platform Ops Runbooks — UX Polish (Filament-native, system theme, live scope) (#137)
## Summary

Implements and polishes the Platform Ops Runbooks feature (Spec 113) — the operator control plane for safe backfills and data repair from `/system`.

## Changes

### UX Polish (Phase 7 — US4)
- **Filament-native components**: Rewrote `runbooks.blade.php` and `view-run.blade.php` using `<x-filament::section>` instead of raw Tailwind div cards. Cards now render correctly with Filament's built-in borders, shadows and dark mode.
- **System panel theme**: Created `resources/css/filament/system/theme.css` and registered `->viteTheme()` on `SystemPanelProvider`. The system panel previously had no theme CSS registered — Tailwind utility classes weren't compiled for its views, causing the warning icon SVG to expand to full container size.
- **Live scope selector**: Added `->live()` to the scope `Radio` field so "Single tenant" immediately reveals the tenant search dropdown without requiring a Submit first.

### Core Feature (Phases 1–6, previously shipped)
- `/system/ops/runbooks` — runbook catalog, preflight, run with typed confirmation + reason
- `/system/ops/runs` — run history table with status/outcome badges
- `/system/ops/runs/{id}` — run detail view with summary counts, failures, collapsible context
- `FindingsLifecycleBackfillRunbookService` — preflight + execution logic
- AllowedTenantUniverse — scopes tenant picker to non-platform tenants only
- RBAC: `platform.ops.view`, `platform.runbooks.view`, `platform.runbooks.run`, `platform.runbooks.findings.lifecycle_backfill`
- Rate-limited `/system/login` (10/min per IP+username)
- Distinct session cookie for `/system` isolation

## Test Coverage
- 16 tests / 141 assertions — all passing
- Covers: page access, RBAC, preflight, run dispatch, scope selector, run detail, run list

## Checklist
- [x] Filament v5 / Livewire v4 compliant
- [x] Provider registered in `bootstrap/providers.php`
- [x] Destructive actions require confirmation (`->requiresConfirmation()`)
- [x] System panel theme registered (`viteTheme`)
- [x] Pint clean
- [x] Tests pass

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #137
2026-02-27 01:11:25 +00:00
32c3a64147 feat(112): LIST $expand parity + Entra principal names (#136)
Implements LIST `$expand` parity with GET by forwarding caller-provided, contract-allowlisted expands.

Key changes:
- Entra Admin Roles scan now requests `expand=principal` for role assignments so `principal.displayName` can render.
- `$expand` normalization/sanitization: top-level comma split (commas inside balanced parentheses preserved), trim, dedupe, allowlist exact match, caps (max 10 tokens, max 200 chars/token).
- Diagnostics when expands are removed/truncated (non-prod warning, production low-noise).

Tests:
- Adds/extends unit coverage for Graph contract sanitization, list request shaping, and the EntraAdminRolesReportService.

Spec artifacts included under `specs/112-list-expand-parity/`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #136
2026-02-25 23:54:20 +00:00
7ac53f4cc4 feat(111): findings workflow + SLA settings (#135)
Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation.

Key changes:
- Findings workflow service + SLA policy and alerting.
- Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults.
- New migrations, jobs, command, UI/resource updates, and comprehensive test coverage.

Tests:
- `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #135
2026-02-25 01:48:01 +00:00
f13a4ce409 feat(110): Ops-UX enterprise start/dedup standard (repo-wide) (#134)
Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces.

Key points
- Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running).
- Dedup paths: canonical “already queued” toast.
- Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately.
- Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract).

Tests & formatting
- Full suite: 1738 passed, 8 skipped (8477 assertions).
- Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass).

Notable change
- Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #134
2026-02-24 09:30:15 +00:00
9f5c99317b Fix Review Pack generation UX + notifications (#133)
## Summary
- Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe).
- Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes.
- Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification.

## Key Changes
- Review Pack generate action now:
  - Shows queued toast only when a new pack is actually created/queued.
  - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack.

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php`

## Notes
- No global search behavior changes for ReviewPacks (still excluded).
- Destructive actions remain confirmation-gated (`->requiresConfirmation()`).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #133
2026-02-23 19:42:52 +00:00
0dc79520a4 feat: provider access hardening (RBAC write gate) (#132)
Implements provider access hardening for Intune write operations:

- RBAC-based write gate with configurable staleness thresholds
- Gate enforced at restore start and in jobs (execute + assignments)
- UI affordances: disabled rerun action, tenant RBAC status card, refresh RBAC action
- Audit logging for blocked writes
- Ops UX label: `rbac.health_check` now displays as “RBAC health check”
- Adds/updates Pest tests and SpecKit artifacts for feature 108

Notes:
- Filament v5 / Livewire v4 compliant.
- Destructive actions require confirmation.
- Assets: no new global assets.

Tested:
- `vendor/bin/sail artisan test --compact` (suite previously green) + focused OpsUx tests for OperationCatalog labels.
- `vendor/bin/sail bin pint --dirty`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #132
2026-02-23 00:49:37 +00:00
e15eee8f26 fix: consolidate tenant creation + harden selection flows (#131)
## Summary
- Removes the legacy Tenant CRUD create page (`/admin/tenants/create`) so tenant creation is handled exclusively via the onboarding wizard.
- Updates tenant selection flows and pages to prevent Livewire polling/notification-related 404s on workspace-scoped routes.
- Aligns empty-state UX with enterprise patterns (avoid duplicate CTAs).

## Key changes
- Tenant creation
  - Removed `CreateTenant` page + route from `TenantResource`.
  - `TenantResource::canCreate()` now returns `false` (CRUD creation disabled).
  - Tenants list now surfaces an **Add tenant** action that links to onboarding (`admin.onboarding`).
- Onboarding wizard
  - Removed redundant legacy step-cards from the blade view (Wizard schema is the source of truth).
  - Disabled topbar on the onboarding page to avoid lazy-loaded notifications.
- Choose tenant
  - Enterprise UI redesign + workspace context.
  - Uses Livewire `selectTenant()` instead of a form POST.
  - Disabled topbar and gated BODY_END hook to avoid background polling.
- Baseline profiles
  - Hide header create action when table is empty to avoid duplicate CTAs.

## Tests
- `vendor/bin/sail artisan test --compact --filter='Onboarding|ManagedTenantOnboarding'`
- `vendor/bin/sail artisan test --compact --filter='ManagedTenantsLivewireUpdate'`
- `vendor/bin/sail artisan test --compact --filter='TenantSetup|TenantResourceAuth|TenantAdminAuth|ListTenants'`
- `vendor/bin/sail artisan test --compact --filter='BaselineProfile'`
- `vendor/bin/sail artisan test --compact --filter='ChooseTenant|TenantMake|TenantScoping|AdminTenantScoped|AdminHomeRedirect|WorkspaceContext'`

## Notes
- Filament v5 / Livewire v4 compatible.
- No new assets introduced; no deploy pipeline changes required.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #131
2026-02-22 19:54:24 +00:00
8bee824966 fix: restore full suite (Pest helper collision + ops-ux spec sync) (#130)
## Why
`dev` full suite was hard-failing with `PHP Fatal error: Cannot redeclare function makeAssignment()` due to two Pest files defining the same global helper.

Additionally, Ops-UX tests were out of sync with the new summary rendering + new whitelisted keys.

## What changed
- Renamed the Entra Admin Roles test helper to `makeEntraAssignment()` to avoid global collision.
- Updated Ops-UX canonical key list in `specs/055-ops-ux-rollout/spec.md` to include:
  - `report_created`, `report_deduped`, `alert_events_produced`
- Updated `SummaryCountsWhitelistTest` to match the new summary rendering:
  - no `Summary:` prefix
  - humanized keys (`Total`, `Processed`)

## Verification
- `vendor/bin/sail artisan test --compact`:
  - **1572 passed**, **7 skipped** (8044 assertions)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #130
2026-02-22 11:36:27 +00:00
33a2b1a242 feat(106): Required Permissions sidebar stays on workspace nav (#129)
## Summary

Fixes the sidebar context bug where navigating to the **Required Permissions** page (`/admin/tenants/{id}/required-permissions`) would switch the sidebar from workspace navigation to tenant-scoped navigation, confusing users.

## Problem

The `EnsureFilamentTenantSelected` middleware detected a tenant ID in the URL and called `setTenant()`, which switched the entire sidebar to tenant-scoped navigation. The Required Permissions page is logically a **workspace-level** page that happens to reference a tenant — it should keep showing workspace nav.

## Changes

### Middleware (`EnsureFilamentTenantSelected.php`)
- **`isWorkspaceScopedPageWithTenant()`** — new private helper that detects workspace-scoped pages containing a tenant parameter via regex
- **Livewire referer bypass** — checks if a Livewire request originates from a workspace-scoped page and preserves workspace nav
- **`setTenant()` bypass** — skips tenant activation and `rememberLastTenantId()` for workspace-scoped pages

### Tests
- **`RequiredPermissionsSidebarTest.php`** (NEW) — 7 tests covering:
  - Workspace nav visible on required-permissions page
  - Tenant nav absent on required-permissions page
  - Direct URL access preserves workspace nav
  - 404 for non-member tenants
  - 404 for tenants without entitlement
  - Tenant pages still show tenant sidebar (regression guard)
  - Scoped tenant resolves correctly on tenant pages

### Pre-existing test fixes
- **`RequiredPermissionsEmptyStateTest`** — fixed URL assertion (dynamic `TenantResource::getUrl()` instead of hardcoded `/admin/onboarding`)
- **`RequiredPermissionsLinksTest`** — fixed URL assertion + multiline HTML `data-testid` assertion
- **`RequiredPermissionsFiltersTest`** — fixed `entra_permissions` config leak from branch 105

## Test Results

| Suite | Result |
|-------|--------|
| RequiredPermissions (26 tests) | **26 pass** (73 assertions) |
| Full regression (1571 tests) | **1562 pass**, 2 fail (pre-existing OpsUx), 7 skipped |

The 2 failures are pre-existing in `OpsUx/OperationCatalogCoverageTest` and `OpsUx/OperationSummaryKeysSpecTest` — unrelated to this feature.

## Spec Artifacts

- `specs/106-required-permissions-sidebar-context/plan.md`
- `specs/106-required-permissions-sidebar-context/tasks.md`
- `specs/106-required-permissions-sidebar-context/research.md`
- `specs/106-required-permissions-sidebar-context/data-model.md`
- `specs/106-required-permissions-sidebar-context/quickstart.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #129
2026-02-22 02:42:44 +00:00
6a15fe978a feat: Spec 105 — Entra Admin Roles Evidence + Findings (#128)
## Summary

Automated scanning of Entra ID directory roles to surface high-privilege role assignments as trackable findings with alerting support.

## What's included

### Core Services
- **EntraAdminRolesReportService** — Fetches role definitions + assignments via Graph API, builds payload with fingerprint deduplication
- **EntraAdminRolesFindingGenerator** — Creates/resolves/reopens findings based on high-privilege role catalog
- **HighPrivilegeRoleCatalog** — Curated list of high-privilege Entra roles (Global Admin, Privileged Auth Admin, etc.)
- **ScanEntraAdminRolesJob** — Queued job orchestrating scan → report → findings → alerts pipeline

### UI
- **AdminRolesSummaryWidget** — Tenant dashboard card showing last scan time, high-privilege assignment count, scan trigger button
- RBAC-gated: `ENTRA_ROLES_VIEW` for viewing, `ENTRA_ROLES_MANAGE` for scan trigger

### Infrastructure
- Graph contracts for `entraRoleDefinitions` + `entraRoleAssignments`
- `config/entra_permissions.php` — Entra permission registry
- `StoredReport.fingerprint` migration (deduplication support)
- `OperationCatalog` label + duration for `entra.admin_roles.scan`
- Artisan command `entra:scan-admin-roles` for CLI/scheduled use

### Global UX improvement
- **SummaryCountsNormalizer**: Zero values filtered, snake_case keys humanized (e.g. `report_deduped: 1` → `Report deduped: 1`). Affects all operation notifications.

## Test Coverage
- **12 test files**, **79+ tests**, **307+ assertions**
- Report service, finding generator, job orchestration, widget rendering, alert integration, RBAC enforcement, badge mapping

## Spec artifacts
- `specs/105-entra-admin-roles-evidence-findings/tasks.md` — Full task breakdown (38 tasks, all complete)
- `specs/105-entra-admin-roles-evidence-findings/checklists/requirements.md` — All items checked

## Files changed
46 files changed, 3641 insertions(+), 15 deletions(-)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #128
2026-02-22 02:37:36 +00:00
ef380b67d1 feat(104): Provider Permission Posture (#127)
Implements Spec 104: Provider Permission Posture.

What changed
- Generates permission posture findings after each tenant permission compare (queued)
- Stores immutable posture snapshots as StoredReports (JSONB payload)
- Adds global Finding resolved lifecycle (`resolved_at`, `resolved_reason`) with `resolve()` / `reopen()`
- Adds alert pipeline event type `permission_missing` (Alerts v1) and Filament option for Alert Rules
- Adds retention pruning command + daily schedule for StoredReports
- Adds badge mappings for `resolved` finding status and `permission_posture` finding type

UX fixes discovered during manual verification
- Hide “Diff” section for non-drift findings (only drift findings show diff)
- Required Permissions page: “Re-run verification” now links to Tenant view (not onboarding)
- Preserve Technical Details `<details>` open state across Livewire re-renders (Alpine state)

Verification
- Ran `vendor/bin/sail artisan test --compact --filter=PermissionPosture` (50 tests)
- Ran `vendor/bin/sail artisan test --compact --filter="FindingResolved|FindingBadge|PermissionMissingAlert"` (20 tests)
- Ran `vendor/bin/sail bin pint --dirty`

Filament v5 / Livewire v4 compliance
- Filament v5 + Livewire v4: no Livewire v3 usage.

Panel provider registration (Laravel 11+)
- No new panels added. Existing panel providers remain registered via `bootstrap/providers.php`.

Global search rule
- No changes to global-searchable resources.

Destructive actions
- No new destructive Filament actions were added in this PR.

Assets / deploy notes
- No new Filament assets registered. Existing deploy step `php artisan filament:assets` remains unchanged.

Test coverage
- New/updated Pest feature tests cover generator behavior, job integration, alerting, retention pruning, and resolved lifecycle.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #127
2026-02-21 22:32:52 +00:00
d32b2115a8 Spec 103: IA semantics (scope vs filter vs targeting) + UI polish (#126)
Implements Spec 103 (IA semantics: Scope vs Filter vs Targeting) across Monitoring + Manage.

Changes
- Monitoring tenant indicator copy: “All tenants” / “Filtered by tenant: …”
- Alerts KPI header resolves tenant via OperateHubShell::activeEntitledTenant() for consistency
- Manage list pages (Alert Rules / Destinations) no longer show tenant indicator
- AlertRule form uses targeting semantics + sections (Rule / Applies to / Delivery)
- Additional UI polish: resource sections, tenant view widgets layout, RBAC progressive disclosure (“Not configured” when empty)

Notes
- US6 (“Add current tenant” convenience button) intentionally skipped (optional P3).

Testing
- CI=1 vendor/bin/sail artisan test tests/Feature/TenantRBAC/ tests/Feature/Onboarding/OnboardingIdentifyTenantTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #126
2026-02-21 00:28:15 +00:00
558b5d3807 Fix tenant dashboard 500 (missing BaselineCompareRun) (#125)
## Summary
Fixes a tenant dashboard Internal Server Error caused by `App\\Models\\BaselineCompareRun` being referenced but not existing.

## What changed
- Dashboard widget now uses `OperationRun` (`type=baseline_compare`, context baseline_profile_id, ordered by completed_at) instead of the missing model.
- Added regression test ensuring tenant dashboard renders when a baseline assignment exists.

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Feature/Filament/TenantDashboardTenantScopeTest.php`

## Notes
No UX changes; this is a runtime stability fix only.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #125
2026-02-20 21:07:34 +00:00
8f8bc24d1d feat: upgrade Filament to v5.2.1 (#124)
What: Upgrade Filament auf v5.2.1 (inkl. composer.lock + veröffentlichte Filament assets unter public), SpecKit-Doku unter specs/102-..., plus kleine Anpassungen in Tests + Tenant-Scoping in BackupSetResource.
Verification: vendor/bin/sail bin pint --dirty --format agent (pass) + vendor/bin/sail artisan test --compact [AlertDeliveryViewerTest.php](http://_vscodecontentref_/6) tests/Feature/Filament/BackupSetGraphSafetyTest.php (pass)
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #124
2026-02-20 12:20:42 +00:00
a30be84084 Baseline governance UX polish + view Infolist (#123)
Summary:
- Baseline Compare landing: enterprise UI (stats grid, critical drift banner, better actions), navigation grouping under Governance, and Action Surface Contract declaration.
- Baseline Profile view page: switches from disabled form fields to proper Infolist entries for a clean read-only view.
- Fixes tenant name column usages (`display_name` → `name`) in baseline assignment flows.
- Dashboard: improved baseline governance widget with severity breakdown + last compared.

Notes:
- Filament v5 / Livewire v4 compatible.
- Destructive actions remain confirmed (`->requiresConfirmation()`).

Tests:
- `vendor/bin/sail artisan test --compact tests/Feature/Baselines`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #123
2026-02-19 23:56:09 +00:00
d49d33ac27 feat(alerts): test message + last test status + deep links (#122)
Implements feature 100 (Alert Targets):

- US1: “Send test message” action (RBAC + confirmation + rate limit + audit + async job)
- US2: Derived “Last test” status badge (Never/Sent/Failed/Pending) on view + edit surfaces
- US3: “View last delivery” deep link + deliveries viewer filters (event_type, destination) incl. tenantless test deliveries

Tests:
- Full suite green (1348 passed, 7 skipped)
- Added focused feature tests for send test, last test resolver/badges, and deep-link filters

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #122
2026-02-18 23:12:38 +00:00
3ed275cef3 feat(alerts): Monitoring cluster + v1 resources (spec 099) (#121)
Implements spec `099-alerts-v1-teams-email`.

- Monitoring navigation: Alerts as a cluster under Monitoring; default landing is Alert deliveries.
- Tenant panel: Alerts points to `/admin/alerts` and the cluster navigation is hidden in tenant panel.
- Guard compliance: removes direct `Gate::` usage from Alert resources so `NoAdHocFilamentAuthPatternsTest` passes.

Verification:
- Full suite: `1348 passed, 7 skipped` (EXIT=0).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #121
2026-02-18 15:20:43 +00:00
c57f680f39 feat: Workspace settings slices v1 (backup, drift, operations) (#120)
Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold.

Spec
- specs/098-settings-slices-v1-backup-drift-ops/spec.md

What changed
- Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed)
- Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase)
- Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor
- Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping
- Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only

Safety / Compliance notes
- Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php)
- Destructive actions: per-setting reset uses Filament actions with confirmation
- Global search: not affected (no resource changes)
- Assets: no new assets registered; no `filament:assets` changes

Tests
- vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \
  tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \
  tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \
  tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \
  tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \
  tests/Unit/Badges/FindingBadgesTest.php

Formatting
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #120
2026-02-16 03:18:33 +00:00
e241e27853 Settings foundation: workspace controls (#119)
Implements the Settings foundation workspace controls.

Includes:
- Settings foundation UI/controls scoped to workspace context
- Related onboarding/consent flow adjustments as included in branch history

Testing:
- `vendor/bin/sail artisan test --compact --no-ansi --filter=SettingsFoundation`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #119
2026-02-16 01:11:24 +00:00
521fb6baaf Fix tenant permission inserts missing workspace_id (#118)
Fixes a production/queue-worker failure where `tenant_permissions.workspace_id` could be omitted from INSERTs when persisting permission check results.

Changes:
- Ensure `workspace_id` is included in the `updateOrCreate()` attributes array (lookup + create merge).
- Add a regression test covering a tenant instance without `workspace_id` loaded.

Notes:
- Queue workers should be restarted (`queue:restart`) after deploy so they pick up the new code.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #118
2026-02-16 00:58:34 +00:00
ef5c223172 fix(onboarding): preserve workspace scope and consent flow (#117)
## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #117
2026-02-15 22:27:55 +00:00
9d0c884251 fix: prevent null workspace_id in tenant_permissions (#116)
Adds workspace_id to the TenantPermission::updateOrCreate(...) payload and gates persistence when $tenant->workspace_id is null: TenantPermissionService.php
Updates/extends tests so this is covered:
Persists with workspace even if events are disabled
Does not persist at all when tenant workspace is missing
TenantPermissionServiceTest.php

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #116
2026-02-15 21:56:37 +00:00
03127a670b Spec 096: Ops polish (assignment summaries + dedupe + reconcile tracking + seed DX) (#115)
Implements Spec 096 ops polish bundle:

- Persist durable OperationRun.summary_counts for assignment fetch/restore (final attempt wins)
- Server-side dedupe for assignment jobs (15-minute cooldown + non-canonical skip)
- Track ReconcileAdapterRunsJob via workspace-scoped OperationRun + stable failure codes + overlap prevention
- Seed DX: ensure seeded tenants use UUID v4 external_id and seed satisfies workspace_id NOT NULL constraints

Verification (local / evidence-based):
- `vendor/bin/sail artisan test --compact tests/Feature/Operations/AssignmentRunSummaryCountsTest.php tests/Feature/Operations/AssignmentJobDedupeTest.php tests/Feature/Operations/ReconcileAdapterRunsJobTrackingTest.php tests/Feature/Seed/PoliciesSeederExternalIdTest.php`
- `vendor/bin/sail bin pint --dirty`

Spec artifacts included under `specs/096-ops-polish-assignment-dedupe-system-tracking/` (spec/plan/tasks/checklists).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #115
2026-02-15 20:49:38 +00:00
eec93b510a Spec 095: Graph contracts registry completeness + registry-backed call sites (#114)
Implements Spec 095.

What changed
- Registers 4 Graph resources in the contract registry (plus required subresource template)
- Refactors in-scope call sites to resolve Graph paths via the registry (no ad-hoc endpoints for these resources)
- Adds/updates regression tests to prevent future drift (missing registry entries and endpoint string reintroduction)
- Includes full SpecKit artifacts under specs/095-graph-contracts-registry-completeness/

Validation
- Focused tests:
  - `vendor/bin/sail artisan test --compact tests/Feature/Graph/GraphContractRegistryCoverageSpec095Test.php tests/Feature/SettingsCatalogDefinitionResolverTest.php`

Notes
- Livewire v4.0+ / Filament v5 compliant (no UI changes).
- No new routes/pages; no RBAC model changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #114
2026-02-15 15:02:27 +00:00
bda1d90fc4 Spec 094: Assignment ops observability hardening (#113)
Implements spec 094 (assignment fetch/restore observability hardening):

- Adds OperationRun tracking for assignment fetch (during backup) and assignment restore (during restore execution)
- Normalizes failure codes/reason_code and sanitizes failure messages
- Ensures exactly one audit log entry per assignment restore execution
- Enforces correct guard/membership vs capability semantics on affected admin surfaces
- Switches assignment Graph services to depend on GraphClientInterface

Also includes Postgres-only FK defense-in-depth check and a discoverable `composer test:pgsql` runner (scoped to the FK constraint test).

Tests:
- `vendor/bin/sail artisan test --compact` (passed)
- `vendor/bin/sail composer test:pgsql` (passed)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #113
2026-02-15 14:08:14 +00:00
92a36ab89e SCOPE-001: DB-level workspace isolation via workspace_id (#112)
Implements Spec 093 (SCOPE-001) workspace isolation at the data layer.

What changed
- Adds `workspace_id` to 12 tenant-owned tables and enforces correct binding.
- Model write-path enforcement derives workspace from tenant + rejects mismatches.
- Prevents `tenant_id` changes (immutability) on tenant-owned records.
- Adds queued backfill command + job (`tenantpilot:backfill-workspace-ids`) with OperationRun + AuditLog observability.
- Enforces DB constraints (NOT NULL + FK `workspace_id` → `workspaces.id` + composite FK `(tenant_id, workspace_id)` → `tenants(id, workspace_id)`), plus audit_logs invariant.

UI / operator visibility
- Monitor backfill runs in **Monitoring → Operations** (OperationRun).

Tests
- `vendor/bin/sail artisan test --compact tests/Feature/WorkspaceIsolation`

Notes
- Backfill is queued: ensure a queue worker is running (`vendor/bin/sail artisan queue:work`).

Spec package
- `specs/093-scope-001-workspace-id-isolation/` (plan, tasks, contracts, quickstart, research)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #112
2026-02-14 22:34:02 +00:00
3ddf8c3fd6 Constitution v1.8.2: scope ownership + spec scope fields (#111)
Amends the constitution to clarify admin vs tenant-context vs workspace-context, and codifies data ownership boundaries.

Changes:
- RBAC Context plane clarification:
  - Tenant/Admin plane: `/admin`
  - Tenant-context routes: `/admin/t/{tenant}/...` (tenant-scoped)
  - Workspace-context canonical routes under `/admin/...` must still enforce entitlement before revealing tenant-owned records
- Adds SCOPE-001 (Scope & Ownership Clarification) under Tenant Isolation.
- Extends RBAC-UX-007 with workspace-context Global Search rules.
- Adds SCOPE-002 (Spec Scope Fields) to Filament Action Surface Contract DoD gates.
- Version bump: 1.8.2, Last Amended: 2026-02-14.

Propagation:
- Updates SpecKit templates to include Spec Scope Fields and updated RBAC plane wording.

Files:
- `.specify/memory/constitution.md`
- `.specify/templates/spec-template.md`
- `.specify/templates/plan-template.md`
- `.specify/templates/tasks-template.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #111
2026-02-14 19:40:29 +00:00
5770c7b76b Spec 092: Legacy Purge (runs/routes/UI/test shims) (#110)
Implements Spec 092 legacy purge.

Key changes:
- Remove legacy Inventory landing page + view; link Inventory entry directly to Inventory Items.
- Update Drift landing copy to "operation runs"; remove URL heuristic from context bar.
- Remove legacy redirect shim route and assert 404 for old bookmarks.
- Staged job payload change: remove legacy ctor arg; keep legacy field for deserialization compatibility; new payload omits field.
- Remove legacy notification artifact.
- Remove legacy test shim + update tests; strengthen guard suite with scoped exception for job compat field.
- Add spec/plan/tasks/checklist artifacts under specs/092-legacy-purge-final.

Tests:
- Focused Pest suite for guards, legacy routes, redirect behavior, job compatibility, drift copy.
- Pint run: `vendor/bin/sail bin pint --dirty`.

Notes:
- Deploy B final removal of `backupScheduleRunId` should occur only after the compatibility window defined in the spec.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #110
2026-02-14 18:43:56 +00:00
1c098441aa feat(spec-091): BackupSchedule lifecycle + create-CTA placement rule (#109)
Implements Spec 091 “BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete)”.

- BackupSchedule lifecycle:
  - Archive (soft delete) with confirmation; restores via Restore action; Force delete with confirmation and strict gating.
  - Force delete blocked when historical runs exist.
  - Archived schedules never dispatch/execute (dispatcher + job guard).
  - Audit events emitted for archive/restore/force delete.
  - RBAC UX semantics preserved (non-member hidden/404; member w/o capability disabled + server-side 403).

- Filament UX contract update:
  - Create CTA placement rule across create-enabled list pages:
    - Empty list: only large centered empty-state Create CTA.
    - Non-empty list: only header Create action.
  - Tests added/updated to enforce the rule.

Verification:
- `vendor/bin/sail bin pint --dirty`
- Focused tests: BackupScheduling + RBAC enforcement + EmptyState CTAs + Create CTA placement

Notes:
- Filament v5 / Livewire v4 compliant.
- Manual quickstart verification in `specs/091-backupschedule-retention-lifecycle/quickstart.md` remains to be checked (T031).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #109
2026-02-14 13:46:06 +00:00
90bfe1516e feat(spec-090): action surface contract compliance (#108)
Implements Spec 090 (Action Surface Contract Compliance & RBAC Hardening).

Highlights:
- Adds/updates action surface declarations and shrinks baseline exemptions.
- Standardizes Filament action grouping/order and empty-state CTAs.
- Enforces RBAC UX semantics (non-member -> 404, member w/o capability -> disabled + tooltip, server-side 403).
- Adds audit logging for successful side-effect actions.
- Fixes Provider Connections list context so header create + row actions resolve tenant correctly.

Tests (focused):
- vendor/bin/sail artisan test --compact tests/Feature/090/
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- vendor/bin/sail bin pint --dirty

Livewire/Filament:
- Filament v5 + Livewire v4 compliant.
- No panel provider registration changes (Laravel 11+ registration remains in bootstrap/providers.php).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #108
2026-02-13 01:30:22 +00:00
fb4de17c63 feat(spec-089): provider connections tenantless UI (#107)
Implements Spec 089: moves Provider Connections to canonical tenantless route under `/admin/provider-connections`, enforces 404/403 semantics (workspace/tenant membership vs capability), adds tenant transparency (tenant column + filter + deep links), adds legacy redirects for old tenant-scoped URLs without leaking Location for 404 cases, and adds regression test coverage (RBAC semantics, filters, UI enforcement tooltips, Microsoft-only MVP scope, navigation placement).

Notes:
- Filament v5 / Livewire v4 compatible.
- Global search remains disabled for Provider Connections.
- Destructive/manage actions require confirmation and are policy-gated.

Tests:
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #107
2026-02-12 16:35:13 +00:00
d6e7de597a feat(spec-087): remove legacy runs (#106)
Implements Spec 087: Legacy Runs Removal (rigorous).

### What changed
- Canonicalized run history: **`operation_runs` is the only run system** for inventory sync, Entra group sync, backup schedule execution/retention/purge.
- Removed legacy UI surfaces (Filament Resources / relation managers) for legacy run models.
- Legacy run URLs now return **404** (no redirects), with RBAC semantics preserved (404 vs 403 as specified).
- Canonicalized affected `operation_runs.type` values (dotted → underscore) via migration.
- Drift + inventory references now point to canonical operation runs; includes backfills and then drops legacy FK columns.
- Drops legacy run tables after cutover.
- Added regression guards to prevent reintroducing legacy run tokens or “backfilling” canonical runs from legacy tables.

### Migrations
- `2026_02_12_000001..000006_*` canonicalize types, add/backfill operation_run_id references, drop legacy columns, and drop legacy run tables.

### Tests
Focused pack for this spec passed:
- `tests/Feature/Guards/NoLegacyRunsTest.php`
- `tests/Feature/Guards/NoLegacyRunBackfillTest.php`
- `tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php`
- `tests/Feature/Monitoring/MonitoringOperationsTest.php`
- `tests/Feature/Jobs/RunInventorySyncJobTest.php`

### Notes / impact
- Destructive cleanup is handled via migrations (drops legacy tables) after code cutover; deploy should run migrations in the same release.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #106
2026-02-12 12:40:51 +00:00
1acbf8cc54 feat(spec-088): remove tenant graphOptions legacy path (#105)
## Summary
- remove tenant-based Graph options access from runtime service paths and enforce provider-only resolution
- add `MicrosoftGraphOptionsResolver` and `ProviderConfigurationRequiredException` for centralized, actionable provider-config errors
- turn `Tenant::graphOptions()` into a fail-fast kill switch to prevent legacy runtime usage
- add and update tests (including guardrail) to enforce no reintroduction in `app/`
- update Spec 088 artifacts (`spec`, `plan`, `research`, `tasks`, checklist)

## Validation
- `vendor/bin/sail bin pint --dirty`
- `vendor/bin/sail artisan test --compact --filter=NoLegacyTenantGraphOptions`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament`
- `CI=1 vendor/bin/sail artisan test --compact`

## Notes
- Branch includes the guardrail test for legacy callsite detection in `app/`.
- Full suite currently green: 1227 passed, 5 skipped.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #105
2026-02-12 10:14:44 +00:00
57f3e3934c 085-tenant-operate-hub (#104)
Summary
Implements Spec 085 “Tenant Operate Hub” semantics so central Monitoring pages are context-aware when entered from a tenant, without changing canonical URLs or implicitly mutating tenant selection. Also fixes a UX leak where tenant-scoped Inventory/Policies/Backups surfaces could appear in Admin navigation / be reachable without a selected tenant.

Why

Reduce “where am I / lost tenant context” confusion when operators jump between tenant work and central Monitoring.
Preserve deny-as-not-found security semantics and avoid tenant identity leaks.
Keep tenant-scoped data surfaces strictly tenant-scoped (not workspace-scoped).
What changed

Context-aware Monitoring:
/admin/operations shows scope label + CTAs (“Back to <tenant>”, “Show all tenants”) when tenant context is active and entitled.
/admin/operations/{run} shows deterministic back affordances + optional escape hatch (“Show all operations”) when tenant context is active and entitled.
Canonical Monitoring GET routes do not mutate tenant context.
Stale tenant context (not entitled) falls back to workspace scope without leaking tenant identity.
Tenant navigation IA:
Tenant panel sidebar provides “Monitoring” shortcuts (Runs/Alerts/Audit Log) into the central Monitoring surfaces.
Tenant-scoped Admin surfaces guard:
Inventory/Policies/Policy Versions/Backup Sets no longer show up tenantless; direct access redirects to /admin/choose-tenant when no tenant is selected.
Tests

Added/updated Pest coverage for:
Spec 085 header affordances + stale-context behavior
deny-as-not-found regressions for non-members/non-entitled users
“DB-only render” (no outbound calls) for Monitoring pages
tenant-scoped admin surfaces redirect when no tenant selected
Compatibility / Constraints

Filament v5 + Livewire v4 compliant (no v3 APIs).
Panel providers remain registered via providers.php (Laravel 11+/12).
No new assets; no changes to filament:assets deployment requirements.
No global search changes.
Manual verification

From a tenant, click “Monitoring → Runs” and confirm:
Scope label shows tenant scope
“Show all tenants” clears tenant context and returns to workspace scope
Open a run detail and confirm “Back to <tenant>” behavior + “Show all operations”.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@ebc83aaa-d947-4a08-b88e-bd72ac9645f7.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #104
2026-02-11 21:01:23 +00:00
2bf5de4663 085-tenant-operate-hub (#103)
Summary

Consolidates the “Tenant Operate Hub” work (Spec 085) and the follow-up adjustments from the 086 session merge into a single branch ready to merge into dev.
Primary focus: stabilize Ops/Operate Hub UX flows, tighten/align authorization semantics, and make the full Sail test suite green.
Key Changes

Ops UX / Verification
Readonly members can view verification operation runs (reports) while starting verification remains restricted.
Normalized failure reason-code handling and aligned UX expectations with the provider reason-code taxonomy.
Onboarding wizard UX
“Start verification” CTA is hidden while a verification run is active; “Refresh” is shown during in-progress runs.
Treats provider_permission_denied as a blocking reason (while keeping legacy compatibility).
Test + fixture hardening
Standardized use of default provider connection fixtures in tests where sync/restore flows require it.
Fixed multiple Filament URL/tenant-context test cases to avoid 404s and reduce tenancy routing brittleness.
Policy sync / restore safety
Enrollment configuration type collision classification tests now exercise the real sync path (with required provider connection present).
Restore edge-case safety tests updated to reflect current provider-connection requirements.
Testing

vendor/bin/sail artisan test --compact (green)
vendor/bin/sail bin pint --dirty (green)
Notes

Includes merged 086 session work already (no separate PR needed).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@ebc83aaa-d947-4a08-b88e-bd72ac9645f7.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #103
2026-02-11 13:02:03 +00:00
0e2adeab71 feat(verification): unify verification surfaces (Spec 084) (#102)
Implements Spec 084 (verification-surfaces-unification).

Highlights
- Unifies tenant + onboarding verification start on `provider.connection.check` (OperationRun-based, enqueue-only).
- Ensures completed blocked runs persist a schema-valid `context.verification_report` stub (DB-only viewers never show “unavailable”).
- Adds tenant embedded verification report widget with DB-only rendering + canonical tenantless “View run” links.
- Enforces 404/403 semantics for tenantless run viewing (workspace membership + tenant entitlement required; otherwise 404).
- Fixes admin panel widgets to resolve tenant from record context so Owners can start verification and recent operations renders correctly.

Tests
- Ran: `vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/Filament/TenantVerificationReportWidgetTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`

Notes
- Filament v5 / Livewire v4 compatible.
- No new assets; no changes to provider registration.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #102
2026-02-09 11:28:09 +00:00
55166cf9b8 Spec 083: Required permissions hardening (canonical /admin/tenants, DB-only, 404 semantics) (#101)
Implements Spec 083 (Canonical Required Permissions manage surface hardening + issues-first UX).

Highlights:
- Enforces canonical route: /admin/tenants/{tenant}/required-permissions
- Legacy tenant-plane URL /admin/t/{tenant}/required-permissions stays non-existent (404)
- Deny-as-not-found (404) for non-workspace members and non-tenant-entitled users
- Strict tenant resolution (no cross-plane fallback)
- DB-only render (no external provider calls on page load)
- Issues-first layout + canonical next-step links (re-run verification -> /admin/onboarding)
- Freshness/stale detection (missing or >30 days -> warning)

Tests (Sail):
- vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
- vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php

Notes:
- Filament v5 / Livewire v4 compliant.
- No destructive actions added in this spec; link-only CTAs.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #101
2026-02-08 23:13:25 +00:00
a770b32e87 feat: action-surface contract inspect affordance + clickable rows (#100)
Implements Spec 082 updates to the Filament Action Surface Contract:

- New required list/table slot: InspectAffordance (clickable row via recordUrl preferred; also supports View action or primary link column)
- Retrofit view-only tables to remove lone View row action buttons and use clickable rows
- Update validator + guard tests, add golden regression assertions
- Add docs: docs/ui/action-surface-contract.md

Tests (local via Sail):
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupSyncRunResourceTest.php

Notes:
- Filament v5 / Livewire v4 compatible.
- No destructive-action behavior changed in this PR.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #100
2026-02-08 20:31:36 +00:00
11c73abd1d docs: Filament Action Surface Contract (constitution + templates) (#99)
Adds the non-negotiable Filament UI Action Surface Contract to the project constitution (v1.7.0) and propagates required gates into Spec Kit templates.

Changes:
- .specify/memory/constitution.md: new contract section + version bump + Sync Impact Report update
- .specify/templates/spec-template.md: adds mandatory UI Action Matrix section for Filament changes
- .specify/templates/plan-template.md: adds contract checks to Constitution Check gate
- .specify/templates/tasks-template.md: adds task/CI enforcement requirements for Filament action surfaces

Notes:
- .specify/templates/commands/ does not exist in this repo, so no command templates were updated.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #99
2026-02-08 12:54:33 +00:00
2503 changed files with 275246 additions and 13480 deletions

View File

@ -0,0 +1,167 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
license: MIT
metadata:
author: laravel
---
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
<!-- Basic Pest Test Example -->
```php
it('is true', function () {
expect(true)->toBeTrue();
});
```
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<!-- Pest Response Assertion -->
```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
```
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 4 Features
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Test Sharding
Split tests across parallel processes for faster CI runs.
### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
```
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests

View File

@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

4
.codex/config.toml Normal file
View File

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

View File

@ -0,0 +1,76 @@
---
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
---
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
## Audit focus
Prioritize:
- workspace and tenant isolation
- route model binding safety
- Filament resources, pages, relation managers, widgets, and actions
- Livewire public properties and serialized state risks
- jobs, queue boundaries, and backend authorization rechecks
- provider access boundaries
- `OperationRun` consistency
- findings, exceptions, review, drift, and baseline workflow integrity
- audit trail completeness
- wrong-tenant regression coverage
- unauthorized action coverage
- workflow misuse and invalid transition coverage
## Output rules
Classify every finding as exactly one of:
- Constitutional Violation
- Architectural Drift
- Workflow Trust Gap
- Test Blind Spot
Assign one severity:
- Severity 1: Critical
- Severity 2: High
- Severity 3: Medium
- Severity 4: Low
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
For each finding provide:
1. Title
2. Classification
3. Severity
4. Affected Area
5. Evidence with specific files, classes, methods, routes, or test gaps
6. Why this matters in TenantPilot
7. Recommended structural correction
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
## Constraints
- Do not praise the codebase.
- Do not focus on style unless it affects architecture or safety.
- Do not suggest random patterns without proving fit.
- Group multiple symptoms under one deeper diagnosis when appropriate.
- Be explicit when a local fix is insufficient and a dedicated spec is required.
## Repository context
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
The strategic priorities are:
- workspace-first context modeling
- capability-first RBAC
- strong auditability
- deterministic workflow semantics
- provider access through canonical boundaries
- minimal duplication of domain logic across UI surfaces
Return the audit as a concise but substantive findings report.

View File

@ -0,0 +1,104 @@
---
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
---
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
Your task is to produce spec candidates, not implementation code.
Before writing anything, read and use these repository files as binding context:
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
- `docs/audits/2026-03-15-audit-spec-candidates.md`
- `specs/110-ops-ux-enforcement/spec.md`
- `specs/111-findings-workflow-sla/spec.md`
- `specs/134-audit-log-foundation/spec.md`
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
## Goal
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
The four candidate themes are:
1. queued execution reauthorization and scope continuity
2. tenant-owned query canon and wrong-tenant guards
3. findings workflow enforcement and audit backstop
4. Livewire context locking and trusted-state reduction
## Numbering rule
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
## Output requirements
Create exactly four spec candidates, one per problem class.
For each candidate provide:
1. Candidate label or confirmed spec number
2. Working title
3. Status: `Proposed`
4. Summary
5. Why this is needed now
6. Boundary to existing specs
7. Problem statement
8. Goals
9. Non-goals
10. Scope
11. Target model
12. Key requirements
13. Risks if not implemented
14. Dependencies and sequencing notes
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
17. Suggested slug
At the end provide:
A. Recommended implementation order
B. Which candidates can run in parallel
C. Which candidate should start first and why
D. A numbering strategy recommendation if active spec numbers are not yet known
## Writing rules
- Write in English.
- Use formal enterprise spec language.
- Be concrete and opinionated.
- Focus on structural integrity, not patch-level fixes.
- Treat the audit constitution as binding.
- Explicitly say when UI-only authorization is insufficient.
- Explicitly say when Livewire public state must be treated as untrusted input.
- Explicitly say when negative-path regression tests are required.
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
- Do not duplicate adjacent specs; state the boundary clearly.
- Do not collapse all four themes into one umbrella spec.
## Candidate-specific direction
### Candidate A — queued execution reauthorization and scope continuity
- Treat this as an execution trust problem, not a simple `authorize()` omission.
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
- Define what happens when authorization or tenant operability changes between dispatch and execution.
### Candidate B — tenant-owned query canon and wrong-tenant guards
- Treat this as canonical data-access hardening.
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
- Focus on ownership enforcement, not generic repository-pattern advice.
### Candidate C — findings workflow enforcement and audit backstop
- Treat this as a workflow-truth problem.
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
- Make clear how this extends but does not duplicate Spec 111.
### Candidate D — Livewire context locking and trusted-state reduction
- Treat this as a UI/server trust-boundary hardening problem.
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
- Make clear how this complements but does not duplicate Spec 138.

View File

@ -1,4 +1,6 @@
node_modules/
dist/
build/
vendor/
coverage/
.git/

View File

@ -73,3 +73,10 @@ ENTRA_AUTHORITY_TENANT=organizations
# System panel break-glass (Platform Operators)
BREAK_GLASS_ENABLED=false
BREAK_GLASS_TTL_MINUTES=60
# Baselines (Spec 118: full-content drift detection)
TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false
TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200
TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5
TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3
TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90

View File

@ -21,6 +21,95 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
- PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail (120-secret-redaction-integrity)
- PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity)
- PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 (121-workspace-switch-fix)
- PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4 (122-empty-state-consistency)
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
- PHP 8.4 runtime target on Laravel 12 code conventions; Composer constraint `php:^8.2` + Laravel 12, Filament v5.2.1, Livewire v4, Pest v4, Laravel Sail (123-operations-auto-refresh)
- PostgreSQL primary app database (123-operations-auto-refresh)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components (125-table-ux-standardization)
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization)
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
- PostgreSQL application database (135-canonical-tenant-context-resolution)
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -40,10 +129,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,76 @@
---
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
---
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
## Audit focus
Prioritize:
- workspace and tenant isolation
- route model binding safety
- Filament resources, pages, relation managers, widgets, and actions
- Livewire public properties and serialized state risks
- jobs, queue boundaries, and backend authorization rechecks
- provider access boundaries
- `OperationRun` consistency
- findings, exceptions, review, drift, and baseline workflow integrity
- audit trail completeness
- wrong-tenant regression coverage
- unauthorized action coverage
- workflow misuse and invalid transition coverage
## Output rules
Classify every finding as exactly one of:
- Constitutional Violation
- Architectural Drift
- Workflow Trust Gap
- Test Blind Spot
Assign one severity:
- Severity 1: Critical
- Severity 2: High
- Severity 3: Medium
- Severity 4: Low
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
For each finding provide:
1. Title
2. Classification
3. Severity
4. Affected Area
5. Evidence with specific files, classes, methods, routes, or test gaps
6. Why this matters in TenantPilot
7. Recommended structural correction
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
## Constraints
- Do not praise the codebase.
- Do not focus on style unless it affects architecture or safety.
- Do not suggest random patterns without proving fit.
- Group multiple symptoms under one deeper diagnosis when appropriate.
- Be explicit when a local fix is insufficient and a dedicated spec is required.
## Repository context
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
The strategic priorities are:
- workspace-first context modeling
- capability-first RBAC
- strong auditability
- deterministic workflow semantics
- provider access through canonical boundaries
- minimal duplication of domain logic across UI surfaces
Return the audit as a concise but substantive findings report.

View File

@ -0,0 +1,105 @@
---
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
agent: speckit.specify
---
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
Your task is to produce spec candidates, not implementation code.
Before writing anything, read and use these repository files as binding context:
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
- `docs/audits/2026-03-15-audit-spec-candidates.md`
- `specs/110-ops-ux-enforcement/spec.md`
- `specs/111-findings-workflow-sla/spec.md`
- `specs/134-audit-log-foundation/spec.md`
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
## Goal
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
The four candidate themes are:
1. queued execution reauthorization and scope continuity
2. tenant-owned query canon and wrong-tenant guards
3. findings workflow enforcement and audit backstop
4. Livewire context locking and trusted-state reduction
## Numbering rule
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
## Output requirements
Create exactly four spec candidates, one per problem class.
For each candidate provide:
1. Candidate label or confirmed spec number
2. Working title
3. Status: `Proposed`
4. Summary
5. Why this is needed now
6. Boundary to existing specs
7. Problem statement
8. Goals
9. Non-goals
10. Scope
11. Target model
12. Key requirements
13. Risks if not implemented
14. Dependencies and sequencing notes
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
17. Suggested slug
At the end provide:
A. Recommended implementation order
B. Which candidates can run in parallel
C. Which candidate should start first and why
D. A numbering strategy recommendation if active spec numbers are not yet known
## Writing rules
- Write in English.
- Use formal enterprise spec language.
- Be concrete and opinionated.
- Focus on structural integrity, not patch-level fixes.
- Treat the audit constitution as binding.
- Explicitly say when UI-only authorization is insufficient.
- Explicitly say when Livewire public state must be treated as untrusted input.
- Explicitly say when negative-path regression tests are required.
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
- Do not duplicate adjacent specs; state the boundary clearly.
- Do not collapse all four themes into one umbrella spec.
## Candidate-specific direction
### Candidate A — queued execution reauthorization and scope continuity
- Treat this as an execution trust problem, not a simple `authorize()` omission.
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
- Define what happens when authorization or tenant operability changes between dispatch and execution.
### Candidate B — tenant-owned query canon and wrong-tenant guards
- Treat this as canonical data-access hardening.
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
- Focus on ownership enforcement, not generic repository-pattern advice.
### Candidate C — findings workflow enforcement and audit backstop
- Treat this as a workflow-truth problem.
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
- Make clear how this extends but does not duplicate Spec 111.
### Candidate D — Livewire context locking and trusted-state reduction
- Treat this as a UI/server trust-boundary hardening problem.
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
- Make clear how this complements but does not duplicate Spec 138.

167
.github/skills/pest-testing/SKILL.md vendored Normal file
View File

@ -0,0 +1,167 @@
---
name: pest-testing
description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
license: MIT
metadata:
author: laravel
---
# Pest Testing 4
## When to Apply
Activate this skill when:
- Creating new tests (unit, feature, or browser)
- Modifying existing tests
- Debugging test failures
- Working with browser testing or smoke testing
- Writing architecture tests or visual regression tests
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
<!-- Basic Pest Test Example -->
```php
it('is true', function () {
expect(true)->toBeTrue();
});
```
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<!-- Pest Response Assertion -->
```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
```
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 4 Features
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Test Sharding
Split tests across parallel processes for faster CI runs.
### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
```
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests

View File

@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

1
.gitignore vendored
View File

@ -32,5 +32,6 @@ Homestead.json
Homestead.yaml
Thumbs.db
/references
/tests/Browser/Screenshots
*.tmp
*.swp

View File

@ -1,18 +1,24 @@
<!--
Sync Impact Report
- Version change: 1.6.0 → 1.7.0
- Version change: 1.12.0 → 1.13.0
- Modified principles:
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate)
- None
- Added sections:
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present in this repo)
- Follow-up TODOs: None
- ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs:
- None.
-->
# TenantPilot Constitution
@ -39,18 +45,43 @@ ### Deterministic Capabilities
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
### Workspace Isolation is Non-negotiable
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
deny-as-not-found (404).
- Workspace is the primary session context. Tenant-scoped routes/resources MUST require an established workspace context.
- Workspace context switching is separate from Filament Tenancy (Managed Tenant switching).
### Tenant Isolation is Non-negotiable
- Every read/write MUST be tenant-scoped.
- Every tenant-plane read/write MUST be tenant-scoped.
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
- Tenantless canonical views (e.g., Monitoring/Operations) MUST enforce tenant entitlement before revealing records.
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
deny-as-not-found (404).
Scope & Ownership Clarification (SCOPE-001)
- The system MUST enforce a strict ownership model:
- Workspace-owned objects define standards, templates, and global configuration (e.g., Baseline Profiles, Notification Targets, Alert Routing Rules, Framework/Control catalogs).
- Tenant-owned objects represent observed state, evidence, and operational artifacts for a specific tenant (e.g., Inventory, Backups/Snapshots, OperationRuns for tenant operations, Drift/Findings, Exceptions/Risk Acceptance, EvidenceItems, StoredReports/Exports).
- Workspace-owned objects MUST NOT directly embed or persist tenant-owned records (no “copying tenant data into templates”).
- Tenant-owned objects MUST always be bound to an established workspace + tenant scope at authorization time.
Database convention:
- Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL.
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id.
- Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope.
- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data.
- Exception: `managed_tenant_onboarding_sessions` MAY keep `tenant_id` nullable as a workspace-scoped workflow-coordination record. It begins before tenant identification, may later reference a tenant for authorization continuity and resume semantics, and MUST always enforce workspace entitlement plus tenant entitlement once a tenant reference is attached. This exception is specific to onboarding draft workflow state and does not create a general precedent for workspace-owned domain records.
### RBAC & UI Enforcement Standards (RBAC-UX)
RBAC Context — Planes, Roles, and Auditability
- The platform MUST maintain two strictly separated authorization planes:
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
- Tenant/Admin plane (`/admin`): authenticated Entra users (`users`).
- Tenant-context routes (`/admin/t/{tenant}/...`) are tenant-scoped.
- Workspace-context canonical routes (`/admin/...`, e.g. Monitoring/Operations) are tenantless by URL but MUST still enforce workspace + tenant entitlement before revealing tenant-owned records.
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
- Tenant role semantics MUST remain least-privilege:
@ -68,15 +99,15 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
- Any missing server-side authorization is a P0 security bug.
RBAC-UX-002 — Deny-as-not-found for non-members
- Tenant membership (and plane membership) is an isolation boundary.
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
- Tenant and workspace membership (and plane membership) are isolation boundaries.
- If the current actor is not a member of the current workspace OR the current tenant (or otherwise not entitled to the
workspace/tenant scope), the system MUST respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
action endpoints (Livewire calls included).
RBAC-UX-003 — Capability denial is 403 (after membership is established)
- Within an established tenant scope, missing permissions are authorization failures.
- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403.
- Within an established workspace + tenant scope, missing permissions are authorization failures.
- If the actor is a workspace + tenant member, but lacks the required capability for an action, the server MUST fail with 403.
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
RBAC-UX-004 — Visible vs disabled UX rule
@ -98,9 +129,12 @@ ### RBAC & UI Enforcement Standards (RBAC-UX)
- CI MUST fail if unknown/unregistered capabilities are used.
RBAC-UX-007 — Global search must be tenant-safe
- Global search results MUST be scoped to the current tenant.
- Global search MUST be context-safe (workspace-context vs tenant-context).
- Non-members MUST never learn about resources in other tenants (no results, no hints).
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
- In workspace-context (no active tenant selected), Global Search MUST NOT return tenant-owned results.
- It MAY search workspace-owned objects only (e.g., Tenants list entries, Baseline Profiles, Alert Rules/Targets, workspace settings).
- If tenant-context is active, Global Search MUST be scoped to the current tenant only (existing rule remains).
RBAC-UX-008 — Regression guards are mandatory
- The repo MUST include RBAC regression tests asserting at least:
@ -130,6 +164,72 @@ ### Operations / Run Observability Standard
- 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,
confirm + “View run”.
### 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:
1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
2) Progress (active awareness only)
- Live progress MUST exist only in:
- the global active-ops widget, and
- Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
3) Terminal DB Notification (audit outcome only)
- Each run MUST emit exactly one persistent terminal DB notification when it becomes terminal.
- Delivery MUST be initiator-only (no tenant-wide fan-out).
- Completion notifications MUST be `OperationRunCompleted` only.
- Feature code MUST NOT send custom completion DB notifications for operations (no `sendToDatabase()` for completion/abort).
Canonical navigation:
- All “View run” links MUST use the canonical helper and MUST point to Monitoring → Operations → Run Detail.
### OperationRun lifecycle is service-owned (OPS-UX-LC-001)
Any change to `OperationRun.status` or `OperationRun.outcome` MUST go through `OperationRunService` (canonical transition method).
This is the only allowed path because it enforces normalization, summary sanitization, idempotency, and terminal notification emission.
Forbidden outside `OperationRunService`:
- `$operationRun->update(['status' => ...])` / `$operationRun->update(['outcome' => ...])`
- `$operationRun->status = ...` / `$operationRun->outcome = ...`
- Query-based updates that transition `status`/`outcome`
Allowed outside the service:
- Updates to `context`, `message`, `reason_code` that do not change `status`/`outcome`.
### Summary counts contract (OPS-UX-SUM-001)
- `operation_runs.summary_counts` is the canonical metrics source for Ops-UX.
- All keys MUST come from `OperationSummaryKeys::all()` (single source of truth).
- Values MUST be flat numeric-only; no nested objects/arrays; no free-text.
- Producers MUST NOT introduce new keys without:
1) updating `OperationSummaryKeys::all()`,
2) updating the spec canonical list,
3) adding/adjusting tests.
### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again.
These guards MUST fail with actionable output (file + snippet).
### Scheduled/system runs (OPS-UX-SYS-001)
- If a run has no initiator user, no terminal DB notification is emitted (initiator-only policy).
- Outcomes remain auditable via Monitoring → Operations / Run Detail.
- Any tenant-wide alerting MUST go through the Alerts system (not `OperationRun` notifications).
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
in failures or notifications.
@ -144,12 +244,17 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
Required surfaces
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
- Create/Edit MUST provide consistent Save/Cancel UX.
Grouping & safety
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
- Bulk actions MUST be grouped via BulkActionGroup.
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
- Relevant mutations MUST write an audit log entry.
@ -161,7 +266,147 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
Spec / DoD gates
- Every spec MUST include a “UI Action Matrix”.
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
- CI MUST enforce the contract (test/command) and block merges on violations.
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
### Filament UI — Layout & Information Architecture Standards (UX-001)
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
Page layout
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
View pages
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
- Long text MUST render as readable prose (not textarea styling).
Empty states
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
Actions & flows
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
Table work-surface defaults
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
Enforcement
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
### Operator-facing UI Naming Standards (UI-NAMING-001)
Goal: operator-facing actions, run labels, notifications, audit prose, and related UI copy MUST use consistent,
enterprise-grade product language.
Naming model
- Operator-facing copy MUST distinguish four layers: Scope, Source/Domain, Operation, and Target Object.
- Scope terms (`Workspace`, `Tenant`) describe execution context and MUST NOT be used as the primary action label unless they are the actual target object.
- Source/Domain terms (`Intune`, `Entra`, `Teams`, future providers) are secondary and MUST NOT lead the primary label unless the current screen presents competing sources that need explicit disambiguation.
Primary action labels
- Primary buttons, header actions, and menu actions MUST use `Verb + Object`.
- Preferred examples: `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, `Export review pack`.
- Forbidden examples: `Sync from tenant`, `Backup tenant`, `Compare tenant`, `Sync from Intune`, `Run tenant sync now`, `Start inventory refresh from provider`.
Domain vocabulary
- Operator-facing copy MUST prefer product-domain objects such as `policies`, `groups`, `baseline`, `findings`, `review pack`, `alerts`, and `operations`.
- Primary operator-facing copy MUST NOT use implementation-first terms such as `provider`, `gateway`, `resolver`, `collector`, `contract registry`, or `job dispatch`.
- Source/domain details MAY appear in modal descriptions, helper text, run metadata, audit metadata, and notifications when needed for precision.
Run, notification, and audit semantics
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD be concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short, e.g. `Policy sync queued`, `Policy sync completed`, `Policy sync failed`, `Baseline compare detected drift`.
- Audit prose MUST use the same operator-facing language, e.g. `{actor} queued policy sync`, `{actor} captured baseline`, `{actor} reopened finding`.
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, and audit prose.
Verb standard
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided for operator-facing copy unless there is a deliberate domain reason.
- `Run` MAY be used only when the object is itself run-like, such as `Run review` or `Run compare`; it MUST NOT be the generic fallback verb for all operations.
Current binding decision
- The Policies screen primary action MUST be `Sync policies`.
- The Policies screen modal title MUST be `Sync policies`.
- The Policies screen success toast MUST be `Policy sync queued`.
- The visible run label for that action MUST be `Policy sync`.
- The audit prose for that action MUST be `{actor} queued policy sync`.
### Operator Surface Principles (OPSURF-001)
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
Operator-first default surfaces
- `/admin` is operator-first.
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
Progressive disclosure for diagnostics
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
Distinct status dimensions
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
- execution outcome
- data completeness
- governance result
- lifecycle or readiness state
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
Explicit mutation scope
- Every action that changes state MUST communicate before execution whether it affects:
- TenantPilot only
- the Microsoft tenant
- simulation only
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
Safe execution for dangerous actions
- Dangerous actions MUST follow a consistent safe-execution pattern:
- configuration
- safety checks or simulation
- preview
- hard confirmation where required
- execute
- One-click destructive actions are not acceptable for high-blast-radius operations.
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
Explicit workspace and tenant context
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
Page contract requirement
- Every new or materially refactored operator-facing page MUST define:
- primary persona
- surface type
- primary operator question
- default-visible information
- diagnostics-only information
- status dimensions used
- mutation scope
- primary actions
- dangerous actions
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
Spec Scope Fields (SCOPE-002)
- Every feature spec MUST declare:
- Scope: workspace | tenant | canonical-view
- Primary Routes
- Data Ownership: workspace-owned vs tenant-owned tables/records impacted
- RBAC: membership requirements + capability requirements
- For canonical-view specs, the spec MUST define:
- Default filter behavior when tenant-context is active (e.g., prefilter to current tenant)
- Explicit entitlement checks that prevent cross-tenant leakage
### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
@ -174,6 +419,50 @@ ### Badge Semantics Are Centralized (BADGE-001)
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
Forbidden local replacements
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
Shared primitive before local override
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
Exception rule
- Ad-hoc markup or styling is allowed only when all of the following are true:
- native Filament components cannot express the required semantics,
- no suitable shared primitive exists,
- and the deviation is justified briefly in code and in the governing spec or PR.
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
Review and enforcement
- Every UI review MUST answer:
- which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken,
- and whether any ad-hoc status or emphasis styling was introduced.
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
### Incremental UI Standards Enforcement (UI-STD-001)
- UI consistency is enforced incrementally, not by recurring cleanup passes.
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
- Deviations MUST be explicit and justified in the spec or PR.
- Canonical standards live in `docs/product/standards/` and are the source of truth for:
- Table UX (column tiers, sort, search, toggle, pagination, persistence, empty states)
- Filter UX (persistence, soft-delete, date range, enum sourcing, defaults)
- Actions UX (row/bulk/header actions, grouping, destructive safety)
- Guard tests enforce critical constraints automatically; the list surface review checklist catches the rest.
- A new spec that adds or modifies a list surface MUST reference the review checklist (`docs/product/standards/list-surface-review-checklist.md`).
### Spec-First Workflow
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
@ -198,4 +487,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26

View File

@ -35,16 +35,30 @@ ## Constitution Check
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- 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
- 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
- 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 guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
## Project Structure
### Documentation (this feature)

View File

@ -5,6 +5,26 @@ # Feature Specification: [FEATURE NAME]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## Spec Scope Fields *(mandatory)*
- **Scope**: [workspace | tenant | canonical-view]
- **Primary Routes**: [List the primary routes/pages affected]
- **Data Ownership**: [workspace-owned vs tenant-owned tables/records impacted]
- **RBAC**: [membership requirements + capability requirements]
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
## User Scenarios & Testing *(mandatory)*
<!--
@ -82,11 +102,18 @@ ## Requirements *(mandatory)*
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**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),
- 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,
- 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.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant `/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),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
@ -100,10 +127,38 @@ ## Requirements *(mandatory)*
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
notifications, audit prose, or related helper copy, the spec MUST describe:
- the target object,
- the operator verb,
- whether source/domain disambiguation is actually needed,
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
- and how implementation-first terms are kept out of primary operator-facing labels.
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
- which diagnostics are secondary and how they are explicitly revealed,
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
- and the page contract for each new or materially refactored operator-facing page.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
@ -129,9 +184,9 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|
| Resource/Page/RM | e.g. app/Filament/... | | | | | | | | |
| Resource/Page/RM | e.g. app/Filament/... | | e.g. `recordUrl()` / View action / linked column | | | | | | | |
### Key Entities *(include if feature involves data)*

View File

@ -14,24 +14,57 @@ # Tasks: [FEATURE NAME]
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
without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- 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),
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- 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,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403,
- capability registry usage (no raw capability strings; no role-string checks in feature code),
- stating which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
- cross-plane deny-as-not-found (404) checks where applicable,
- at least one positive + one negative authorization test.
**UI Naming**: If this feature adds or changes operator-facing actions, run titles, notifications, audit prose, or helper copy, tasks MUST include:
- aligning primary action labels to `Verb + Object`,
- keeping scope terms (`Workspace`, `Tenant`) out of primary action labels unless they are the actual target object,
- using source/domain terms only where same-screen disambiguation is required,
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
- removing implementation-first wording from primary operator-facing copy.
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
- filling the specs Operator Surface Contract for every affected page,
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
- filling the specs “UI Action Matrix” for all changed surfaces,
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
- grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations,
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
- capping header actions to max 1 primary + 1 secondary (rest grouped),
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.

355
Agents.md
View File

@ -389,6 +389,7 @@ ## Reference Materials
=== .ai/filament-v5-blueprint rules ===
## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
@ -398,6 +399,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+.
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
@ -413,6 +415,7 @@ ## 1) Non-negotiables
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
@ -421,6 +424,7 @@ ## 2) Directory & naming conventions
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
@ -434,6 +438,7 @@ ## 3) Panel setup defaults
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
@ -447,6 +452,7 @@ ## 4) Navigation & information architecture
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
@ -459,6 +465,7 @@ ## 5) Resource patterns
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
- Prefer render hooks for layout injection; avoid publishing internal views.
@ -467,6 +474,7 @@ ## 6) Page lifecycle & query rules
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
@ -477,6 +485,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
- Custom field views must obey state binding modifiers.
@ -486,6 +495,7 @@ ## 8) Form patterns (validation, reactivity, state)
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
@ -498,6 +508,7 @@ ## 9) Table & action patterns
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
- Enforce panel access in non-local environments as documented.
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
@ -507,6 +518,7 @@ ## 10) Authorization & security
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
- Default to explicit success/error notifications for user-triggered mutations that arent instantly obvious.
- Treat polling as a cost; set intervals intentionally where polling is used.
@ -515,6 +527,7 @@ ## 11) Notifications & UX feedback
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
@ -524,6 +537,7 @@ ## 12) Performance defaults
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
@ -533,6 +547,7 @@ ## 13) Testing requirements
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
@ -547,6 +562,7 @@ ## 14) Forbidden patterns
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
@ -567,6 +583,7 @@ ## 15) Agent output contract
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
@ -574,6 +591,7 @@ ## Version Safety
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
@ -588,6 +606,7 @@ ## Panel & Navigation
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
@ -596,18 +615,21 @@ ## Resource Structure
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
- [ ] RelationManagers remain lazy-loaded by default unless theres an explicit UX justification.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
## Forms
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
## Tables & Actions
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
@ -616,6 +638,7 @@ ## Tables & Actions
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented.
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
@ -623,24 +646,28 @@ ## Authorization & Security
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
## Performance
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
## Testing
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
@ -648,9 +675,10 @@ ## Deployment / Ops
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.15
@ -666,56 +694,73 @@ ## Foundational Context
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
## Laravel Boost
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
@ -725,38 +770,44 @@ ### Available Search Syntax
=== php rules ===
## PHP
# PHP
- Always use curly braces for control structures, even if it has one line.
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== sail rules ===
## Laravel Sail
# Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
@ -770,20 +821,21 @@ ## Laravel Sail
=== tests rules ===
## Test Enforcement
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
## Do Things the Laravel Way
# Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@ -791,43 +843,53 @@ ### Database
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Authentication & Authorization
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
# Laravel 12
- Use the `search-docs` tool to get version-specific documentation.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
@ -835,224 +897,39 @@ ### Laravel 12 Structure
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
# Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
## Tailwind CSS
# Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind CSS 4
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
## Active Technologies

355
GEMINI.md
View File

@ -229,6 +229,7 @@ ## Reference Materials
=== .ai/filament-v5-blueprint rules ===
## Source of Truth
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
@ -238,6 +239,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
- Filament v5 requires Livewire v4.0+.
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
@ -253,6 +255,7 @@ ## 1) Non-negotiables
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
@ -261,6 +264,7 @@ ## 2) Directory & naming conventions
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
@ -274,6 +278,7 @@ ## 3) Panel setup defaults
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
@ -287,6 +292,7 @@ ## 4) Navigation & information architecture
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
@ -299,6 +305,7 @@ ## 5) Resource patterns
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
- Prefer render hooks for layout injection; avoid publishing internal views.
@ -307,6 +314,7 @@ ## 6) Page lifecycle & query rules
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
@ -317,6 +325,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
- Custom field views must obey state binding modifiers.
@ -326,6 +335,7 @@ ## 8) Form patterns (validation, reactivity, state)
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
@ -338,6 +348,7 @@ ## 9) Table & action patterns
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
- Enforce panel access in non-local environments as documented.
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
@ -347,6 +358,7 @@ ## 10) Authorization & security
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
- Default to explicit success/error notifications for user-triggered mutations that arent instantly obvious.
- Treat polling as a cost; set intervals intentionally where polling is used.
@ -355,6 +367,7 @@ ## 11) Notifications & UX feedback
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
@ -364,6 +377,7 @@ ## 12) Performance defaults
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filaments action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
@ -373,6 +387,7 @@ ## 13) Testing requirements
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
@ -387,6 +402,7 @@ ## 14) Forbidden patterns
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
@ -407,6 +423,7 @@ ## 15) Agent output contract
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
@ -414,6 +431,7 @@ ## Version Safety
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
@ -428,6 +446,7 @@ ## Panel & Navigation
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
@ -436,18 +455,21 @@ ## Resource Structure
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
- [ ] RelationManagers remain lazy-loaded by default unless theres an explicit UX justification.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
## Forms
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
## Tables & Actions
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
@ -456,6 +478,7 @@ ## Tables & Actions
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
- [ ] Panel access is enforced for non-local environments as documented.
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
@ -463,24 +486,28 @@ ## Authorization & Security
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes arent instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
## Performance
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
## Testing
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
- [ ] Actions that mutate data are covered using Filaments action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
@ -488,9 +515,10 @@ ## Deployment / Ops
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.15
@ -506,56 +534,73 @@ ## Foundational Context
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
## Laravel Boost
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
@ -565,38 +610,44 @@ ### Available Search Syntax
=== php rules ===
## PHP
# PHP
- Always use curly braces for control structures, even if it has one line.
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== sail rules ===
## Laravel Sail
# Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
@ -610,20 +661,21 @@ ## Laravel Sail
=== tests rules ===
## Test Enforcement
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
## Do Things the Laravel Way
# Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@ -631,43 +683,53 @@ ### Database
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Authentication & Authorization
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
# Laravel 12
- Use the `search-docs` tool to get version-specific documentation.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
@ -675,224 +737,39 @@ ### Laravel 12 Structure
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
# Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
## Tailwind CSS
# Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind CSS 4
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
## Recent Changes

View File

@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionClassificationResult;
use App\Services\Providers\ProviderConnectionClassifier;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ClassifyProviderConnections extends Command
{
protected $signature = 'tenantpilot:provider-connections:classify
{--tenant= : Restrict to a tenant id, external id, or tenant guid}
{--connection= : Restrict to a single provider connection id}
{--provider=microsoft : Restrict to one provider}
{--chunk=100 : Chunk size for large write runs}
{--write : Persist the classification results}';
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
public function handle(
ProviderConnectionClassifier $classifier,
ProviderConnectionStateProjector $stateProjector,
): int {
$query = $this->query();
$write = (bool) $this->option('write');
$chunkSize = max(1, (int) $this->option('chunk'));
$candidateCount = (clone $query)->count();
if ($candidateCount === 0) {
$this->info('No provider connections matched the classification scope.');
return self::SUCCESS;
}
$tenantCounts = (clone $query)
->selectRaw('tenant_id, count(*) as aggregate')
->groupBy('tenant_id')
->pluck('aggregate', 'tenant_id')
->map(static fn (mixed $count): int => (int) $count)
->all();
$startedTenants = [];
$classifiedCount = 0;
$appliedCount = 0;
$reviewRequiredCount = 0;
$query
->with(['tenant', 'credential'])
->orderBy('id')
->chunkById($chunkSize, function ($connections) use (
$classifier,
$stateProjector,
$write,
$tenantCounts,
&$startedTenants,
&$classifiedCount,
&$appliedCount,
&$reviewRequiredCount,
): void {
foreach ($connections as $connection) {
$classifiedCount++;
$result = $classifier->classify(
$connection,
source: 'tenantpilot:provider-connections:classify',
);
if ($result->reviewRequired) {
$reviewRequiredCount++;
}
if (! $write) {
continue;
}
$tenant = $connection->tenant;
if (! $tenant instanceof Tenant) {
$this->warn(sprintf('Skipping provider connection #%d without tenant context.', (int) $connection->getKey()));
continue;
}
$tenantKey = (int) $tenant->getKey();
if (! array_key_exists($tenantKey, $startedTenants)) {
$this->auditStart($tenant, $tenantCounts[$tenantKey] ?? 0);
$startedTenants[$tenantKey] = true;
}
$connection = $this->applyClassification($connection, $result, $stateProjector);
$this->auditApplied($tenant, $connection, $result);
$appliedCount++;
}
});
if ($write) {
$this->info(sprintf('Applied classifications: %d', $appliedCount));
} else {
$this->info(sprintf('Dry-run classifications: %d', $classifiedCount));
}
$this->info(sprintf('Review required: %d', $reviewRequiredCount));
$this->info(sprintf('Mode: %s', $write ? 'write' : 'dry-run'));
return self::SUCCESS;
}
private function query(): Builder
{
$query = ProviderConnection::query()
->where('provider', (string) $this->option('provider'));
$tenantOption = $this->option('tenant');
if (is_string($tenantOption) && trim($tenantOption) !== '') {
$tenant = Tenant::query()
->forTenant(trim($tenantOption))
->firstOrFail();
$query->where('tenant_id', (int) $tenant->getKey());
}
$connectionOption = $this->option('connection');
if (is_numeric($connectionOption)) {
$query->whereKey((int) $connectionOption);
}
return $query;
}
private function applyClassification(
ProviderConnection $connection,
ProviderConnectionClassificationResult $result,
ProviderConnectionStateProjector $stateProjector,
): ProviderConnection {
DB::transaction(function () use ($connection, $result, $stateProjector): void {
$connection->forceFill(
$connection->classificationProjection($result, $stateProjector)
)->save();
$credential = $connection->credential;
if (! $credential instanceof ProviderCredential) {
return;
}
$updates = [];
if (
$result->suggestedConnectionType === ProviderConnectionType::Dedicated
&& $credential->source === null
) {
$updates['source'] = ProviderCredentialSource::LegacyMigrated->value;
}
if ($credential->credential_kind === null && $credential->type === ProviderCredentialKind::ClientSecret->value) {
$updates['credential_kind'] = ProviderCredentialKind::ClientSecret->value;
}
if ($updates !== []) {
$credential->forceFill($updates)->save();
}
});
return $connection->fresh(['tenant', 'credential']);
}
private function auditStart(Tenant $tenant, int $candidateCount): void
{
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.migration_classification_started',
context: [
'metadata' => [
'source' => 'tenantpilot:provider-connections:classify',
'provider' => 'microsoft',
'candidate_count' => $candidateCount,
'write' => true,
],
],
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
status: 'success',
);
}
private function auditApplied(
Tenant $tenant,
ProviderConnection $connection,
ProviderConnectionClassificationResult $result,
): void {
$effectiveApp = $connection->effectiveAppMetadata();
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'provider_connection.migration_classification_applied',
context: [
'metadata' => [
'source' => 'tenantpilot:provider-connections:classify',
'workspace_id' => (int) $connection->workspace_id,
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'connection_type' => $connection->connection_type->value,
'migration_review_required' => $connection->migration_review_required,
'legacy_identity_result' => $result->suggestedConnectionType->value,
'effective_app_id' => $effectiveApp['app_id'],
'effective_app_source' => $effectiveApp['source'],
'signals' => $result->signals,
],
],
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Console\Command;
class GraphContractCheck extends Command
@ -11,7 +12,7 @@ class GraphContractCheck extends Command
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
public function handle(GraphClientInterface $graph): int
public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int
{
$contracts = config('graph_contracts.types', []);
@ -36,11 +37,13 @@ public function handle(GraphClientInterface $graph): int
continue;
}
$query = array_filter([
$queryInput = array_filter([
'$top' => 1,
'$select' => $select,
'$expand' => $expand,
]);
], static fn ($value): bool => $value !== null && $value !== '' && $value !== []);
$query = $registry->sanitizeQuery($type, $queryInput)['query'];
$response = $graph->request('GET', $resource, [
'query' => $query,

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\PolicyVersion;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use Illuminate\Console\Command;
class PruneBaselineEvidencePolicyVersionsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:baseline-evidence:prune {--days= : Number of days to retain baseline evidence policy versions}';
/**
* @var string
*/
protected $description = 'Soft-delete baseline-capture/baseline-compare policy versions older than the configured retention window';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90));
if ($days < 1) {
$this->error('Retention days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$deleted = PolicyVersion::query()
->whereNull('deleted_at')
->whereIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
])
->where('captured_at', '<', $cutoff)
->delete();
$this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ReviewPack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class PruneReviewPacksCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:review-pack:prune {--hard-delete : Hard-delete expired packs past grace period}';
/**
* @var string
*/
protected $description = 'Expire review packs past retention and optionally hard-delete expired rows past grace period';
public function handle(): int
{
$expired = $this->expireReadyPacks();
$hardDeleted = 0;
if ($this->option('hard-delete')) {
$hardDeleted = $this->hardDeleteExpiredPacks();
}
$this->info("{$expired} pack(s) expired, {$hardDeleted} pack(s) hard-deleted.");
return self::SUCCESS;
}
/**
* Transition ready packs past retention to expired and delete their files.
*/
private function expireReadyPacks(): int
{
$packs = ReviewPack::query()
->ready()
->pastRetention()
->get();
$disk = Storage::disk('exports');
$count = 0;
foreach ($packs as $pack) {
/** @var ReviewPack $pack */
if ($pack->file_path && $disk->exists($pack->file_path)) {
$disk->delete($pack->file_path);
}
$pack->update(['status' => ReviewPack::STATUS_EXPIRED]);
$count++;
}
return $count;
}
/**
* Hard-delete expired packs that are past the grace period.
*/
private function hardDeleteExpiredPacks(): int
{
$graceDays = (int) config('tenantpilot.review_pack.hard_delete_grace_days', 30);
$cutoff = now()->subDays($graceDays);
return ReviewPack::query()
->expired()
->where('updated_at', '<', $cutoff)
->delete();
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\StoredReport;
use Illuminate\Console\Command;
class PruneStoredReportsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'stored-reports:prune {--days= : Number of days to retain reports}';
/**
* @var string
*/
protected $description = 'Delete stored reports older than the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('tenantpilot.stored_reports.retention_days', 90));
if ($days < 1) {
$this->error('Retention days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$deleted = StoredReport::query()
->where('created_at', '<', $cutoff)
->delete();
$this->info("Deleted {$deleted} stored report(s) older than {$days} days.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\OperationRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command
{
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
{--force : Actually delete matched legacy runs}';
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
public function handle(): int
{
if (! app()->environment(['local', 'testing'])) {
$this->error('This cleanup command is limited to local and testing environments.');
return self::FAILURE;
}
$types = $this->normalizedTypes();
$workspaceIds = array_values(array_filter(
array_map(
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
(array) $this->option('workspace'),
),
static fn (int $workspaceId): bool => $workspaceId > 0,
));
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
$limit = max(1, (int) $this->option('limit'));
$dryRun = ! (bool) $this->option('force');
$query = OperationRun::query()
->whereIn('type', $types)
->orderBy('id')
->limit($limit);
if ($workspaceIds !== []) {
$query->whereIn('workspace_id', $workspaceIds);
}
if ($tenantIds !== []) {
$query->whereIn('tenant_id', $tenantIds);
}
$candidates = $query->get();
$matched = $candidates
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
->values();
if ($matched->isEmpty()) {
$this->info('No legacy baseline gap runs matched the current filters.');
return self::SUCCESS;
}
$this->table(
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
$matched
->map(fn (OperationRun $run): array => [
'Run' => (string) $run->getKey(),
'Type' => (string) $run->type,
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
'Legacy signal' => $this->legacySignal($run),
])
->all(),
);
if ($dryRun) {
$this->warn(sprintf(
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
$matched->count(),
));
return self::SUCCESS;
}
OperationRun::query()
->whereKey($matched->modelKeys())
->delete();
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
return self::SUCCESS;
}
/**
* @return array<int, string>
*/
private function normalizedTypes(): array
{
$types = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
}
return array_values(array_filter(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int, int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
if ($tenantIdentifiers === []) {
return [];
}
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()->forTenant($identifier)->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
private function legacySignal(OperationRun $run): string
{
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
if (array_key_exists('policy_not_found', $byReason)) {
return 'legacy_reason_code';
}
return 'legacy_subject_shape';
}
}

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotBackfillFindingLifecycle extends Command
{
protected $signature = 'tenantpilot:findings:backfill-lifecycle
{--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
if ($tenantIdentifiers === []) {
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
return self::FAILURE;
}
$tenants = $this->resolveTenants($tenantIdentifiers);
if ($tenants->isEmpty()) {
$this->info('No tenants matched the provided identifiers.');
return self::SUCCESS;
}
$queued = 0;
$skipped = 0;
$nothingToDo = 0;
foreach ($tenants as $tenant) {
if (! $tenant instanceof Tenant) {
continue;
}
try {
$run = $runbookService->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: null,
reason: null,
source: 'cli',
);
} catch (ValidationException $e) {
$errors = $e->errors();
if (isset($errors['preflight.affected_count'])) {
$nothingToDo++;
continue;
}
$this->error(sprintf(
'Backfill blocked for tenant %d: %s',
(int) $tenant->getKey(),
$e->getMessage(),
));
return self::FAILURE;
}
if (! $run->wasRecentlyCreated) {
$skipped++;
continue;
}
$queued++;
}
$this->info(sprintf(
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
$queued,
$skipped,
$nothingToDo,
));
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return \Illuminate\Support\Collection<int, Tenant>
*/
private function resolveTenants(array $tenantIdentifiers)
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->forTenant($identifier)
->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
$tenantIds = array_values(array_unique($tenantIds));
if ($tenantIds === []) {
return collect();
}
return Tenant::query()
->whereIn('id', $tenantIds)
->orderBy('id')
->get();
}
}

View File

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillWorkspaceIdsJob;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\WorkspaceIsolation\TenantOwnedTables;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class TenantpilotBackfillWorkspaceIds extends Command
{
protected $signature = 'tenantpilot:backfill-workspace-ids
{--dry-run : Print per-table counts only}
{--table= : Restrict to a single tenant-owned table}
{--batch-size=5000 : Rows per queued chunk}
{--resume-from=0 : Resume from id cursor}
{--max-rows= : Maximum rows to process per table job}';
protected $description = 'Backfill missing workspace_id across tenant-owned tables.';
public function handle(OperationRunService $operationRunService, WorkspaceAuditLogger $workspaceAuditLogger): int
{
$tables = $this->resolveTables();
if ($tables === []) {
return self::FAILURE;
}
$batchSize = max(1, (int) $this->option('batch-size'));
$resumeFrom = max(0, (int) $this->option('resume-from'));
$maxRows = $this->normalizeMaxRows();
$dryRun = (bool) $this->option('dry-run');
$lock = Cache::lock('tenantpilot:backfill-workspace-ids', 900);
if (! $lock->get()) {
$this->error('Another workspace backfill is already running.');
return self::FAILURE;
}
try {
$tableStats = $this->collectTableStats($tables);
$this->table(
['Table', 'Missing workspace_id', 'Unresolvable tenant mapping', 'Sample row ids'],
array_map(static function (array $stats): array {
return [
$stats['table'],
$stats['missing'],
$stats['unresolvable'],
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
];
}, $tableStats),
);
$unresolvable = array_values(array_filter($tableStats, static fn (array $stats): bool => $stats['unresolvable'] > 0));
if ($unresolvable !== []) {
foreach ($unresolvable as $stats) {
$this->error(sprintf(
'Unresolvable tenant->workspace mapping in %s (%d rows). Sample ids: %s',
$stats['table'],
$stats['unresolvable'],
$stats['sample_ids'] === [] ? '-' : implode(',', $stats['sample_ids']),
));
}
return self::FAILURE;
}
if ($dryRun) {
$this->info('Dry-run complete. No changes written.');
return self::SUCCESS;
}
$workspaceWorkloads = $this->collectWorkspaceWorkloads($tables, $maxRows);
if ($workspaceWorkloads === []) {
$this->info('No rows require workspace_id backfill.');
return self::SUCCESS;
}
$dispatchedJobs = 0;
foreach ($workspaceWorkloads as $workspaceId => $workload) {
$workspace = Workspace::query()->find($workspaceId);
if (! $workspace instanceof Workspace) {
continue;
}
$run = $operationRunService->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'workspace_isolation_backfill_workspace_ids',
identityInputs: [
'tables' => array_keys($workload['tables']),
],
context: [
'source' => 'tenantpilot:backfill-workspace-ids',
'workspace_id' => (int) $workspace->getKey(),
'batch_size' => $batchSize,
'max_rows' => $maxRows,
'resume_from' => $resumeFrom,
'tables' => array_keys($workload['tables']),
],
);
if (! $run->wasRecentlyCreated) {
$this->line(sprintf(
'Workspace %d already has an active backfill run (#%d).',
(int) $workspace->getKey(),
(int) $run->getKey(),
));
continue;
}
$tableProgress = [];
foreach ($workload['tables'] as $table => $count) {
$tableProgress[$table] = [
'target_rows' => (int) $count,
'processed' => 0,
'last_processed_id' => $resumeFrom,
];
}
$context = is_array($run->context) ? $run->context : [];
$context['table_progress'] = $tableProgress;
$run->update([
'context' => $context,
'summary_counts' => [
'total' => (int) $workload['total'],
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
],
]);
$operationRunService->updateRun($run, status: 'running');
$workspaceAuditLogger->log(
workspace: $workspace,
action: 'workspace_isolation.backfill_workspace_ids.started',
context: [
'operation_run_id' => (int) $run->getKey(),
'tables' => array_keys($workload['tables']),
'planned_rows' => (int) $workload['total'],
'batch_size' => $batchSize,
],
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
$workspaceJobs = 0;
foreach ($workload['tables'] as $table => $tableRows) {
if ($tableRows <= 0) {
continue;
}
BackfillWorkspaceIdsJob::dispatch(
operationRunId: (int) $run->getKey(),
workspaceId: (int) $workspace->getKey(),
table: $table,
batchSize: $batchSize,
maxRows: $maxRows,
resumeFrom: $resumeFrom,
);
$workspaceJobs++;
$dispatchedJobs++;
}
$workspaceAuditLogger->log(
workspace: $workspace,
action: 'workspace_isolation.backfill_workspace_ids.dispatched',
context: [
'operation_run_id' => (int) $run->getKey(),
'jobs_dispatched' => $workspaceJobs,
'tables' => array_keys($workload['tables']),
],
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
$this->line(sprintf(
'Workspace %d run #%d queued (%d job(s)).',
(int) $workspace->getKey(),
(int) $run->getKey(),
$workspaceJobs,
));
}
$this->info(sprintf('Backfill jobs dispatched: %d', $dispatchedJobs));
return self::SUCCESS;
} finally {
$lock->release();
}
}
/**
* @return array<int, string>
*/
private function resolveTables(): array
{
$selectedTable = $this->option('table');
if (! is_string($selectedTable) || trim($selectedTable) === '') {
return TenantOwnedTables::all();
}
$selectedTable = trim($selectedTable);
if (! TenantOwnedTables::contains($selectedTable)) {
$this->error(sprintf('Unknown tenant-owned table: %s', $selectedTable));
return [];
}
return [$selectedTable];
}
private function normalizeMaxRows(): ?int
{
$maxRows = $this->option('max-rows');
if (! is_numeric($maxRows)) {
return null;
}
$maxRows = (int) $maxRows;
return $maxRows > 0 ? $maxRows : null;
}
/**
* @param array<int, string> $tables
* @return array<int, array{table: string, missing: int, unresolvable: int, sample_ids: array<int, int>}>
*/
private function collectTableStats(array $tables): array
{
$stats = [];
foreach ($tables as $table) {
$missing = (int) DB::table($table)->whereNull('workspace_id')->count();
$unresolvableQuery = DB::table($table)
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
->whereNull(sprintf('%s.workspace_id', $table))
->where(function ($query): void {
$query->whereNull('tenants.id')
->orWhereNull('tenants.workspace_id');
});
$unresolvable = (int) $unresolvableQuery->count();
$sampleIds = DB::table($table)
->leftJoin('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
->whereNull(sprintf('%s.workspace_id', $table))
->where(function ($query): void {
$query->whereNull('tenants.id')
->orWhereNull('tenants.workspace_id');
})
->orderBy(sprintf('%s.id', $table))
->limit(5)
->pluck(sprintf('%s.id', $table))
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
$stats[] = [
'table' => $table,
'missing' => $missing,
'unresolvable' => $unresolvable,
'sample_ids' => $sampleIds,
];
}
return $stats;
}
/**
* @param array<int, string> $tables
* @return array<int, array{total: int, tables: array<string, int>}>
*/
private function collectWorkspaceWorkloads(array $tables, ?int $maxRows): array
{
$workloads = [];
foreach ($tables as $table) {
$rows = DB::table($table)
->join('tenants', 'tenants.id', '=', sprintf('%s.tenant_id', $table))
->whereNull(sprintf('%s.workspace_id', $table))
->whereNotNull('tenants.workspace_id')
->selectRaw('tenants.workspace_id as workspace_id, COUNT(*) as row_count')
->groupBy('tenants.workspace_id')
->get();
foreach ($rows as $row) {
$workspaceId = (int) $row->workspace_id;
if ($workspaceId <= 0) {
continue;
}
$rowCount = (int) $row->row_count;
if ($maxRows !== null) {
$rowCount = min($rowCount, $maxRows);
}
if ($rowCount <= 0) {
continue;
}
if (! isset($workloads[$workspaceId])) {
$workloads[$workspaceId] = [
'total' => 0,
'tables' => [],
];
}
$workloads[$workspaceId]['tables'][$table] = $rowCount;
$workloads[$workspaceId]['total'] += $rowCount;
}
}
return $workloads;
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Alerts\DeliverAlertsJob;
use App\Jobs\Alerts\EvaluateAlertsJob;
use App\Models\Workspace;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class TenantpilotDispatchAlerts extends Command
{
protected $signature = 'tenantpilot:alerts:dispatch {--workspace=* : Limit dispatch to one or more workspace IDs}';
protected $description = 'Queue workspace-scoped alert evaluation and delivery jobs idempotently.';
public function handle(OperationRunService $operationRuns): int
{
if (! (bool) config('tenantpilot.alerts.enabled', true)) {
return self::SUCCESS;
}
$workspaceFilter = array_values(array_filter(array_map(
static fn (mixed $value): int => (int) $value,
(array) $this->option('workspace'),
)));
$workspaces = $this->resolveWorkspaces($workspaceFilter);
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
$queuedEvaluate = 0;
$queuedDeliver = 0;
$skippedEvaluate = 0;
$skippedDeliver = 0;
foreach ($workspaces as $workspace) {
$evaluateRun = $operationRuns->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'alerts.evaluate',
identityInputs: ['slot_key' => $slotKey],
context: [
'trigger' => 'scheduled_dispatch',
'slot_key' => $slotKey,
],
initiator: null,
);
if ($evaluateRun->wasRecentlyCreated) {
EvaluateAlertsJob::dispatch((int) $workspace->getKey(), (int) $evaluateRun->getKey());
$queuedEvaluate++;
} else {
$skippedEvaluate++;
}
$deliverRun = $operationRuns->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: 'alerts.deliver',
identityInputs: ['slot_key' => $slotKey],
context: [
'trigger' => 'scheduled_dispatch',
'slot_key' => $slotKey,
],
initiator: null,
);
if ($deliverRun->wasRecentlyCreated) {
DeliverAlertsJob::dispatch((int) $workspace->getKey(), (int) $deliverRun->getKey());
$queuedDeliver++;
} else {
$skippedDeliver++;
}
}
$this->info(sprintf(
'Alert dispatch scanned %d workspace(s): evaluate queued=%d skipped=%d, deliver queued=%d skipped=%d.',
$workspaces->count(),
$queuedEvaluate,
$skippedEvaluate,
$queuedDeliver,
$skippedDeliver,
));
return self::SUCCESS;
}
/**
* @param array<int, int> $workspaceIds
* @return Collection<int, Workspace>
*/
private function resolveWorkspaces(array $workspaceIds): Collection
{
return Workspace::query()
->when(
$workspaceIds !== [],
fn ($query) => $query->whereIn('id', $workspaceIds),
fn ($query) => $query->whereHas('tenants'),
)
->orderBy('id')
->get();
}
}

View File

@ -3,9 +3,9 @@
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class TenantpilotDispatchDirectoryGroupsSync extends Command
{
@ -46,27 +46,38 @@ public function handle(): int
$skipped = 0;
foreach ($tenants as $tenant) {
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
'status' => 'pending',
'initiator_user_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'entra_group_sync',
identityInputs: [
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
],
context: [
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
'trigger' => 'scheduled',
],
initiator: null,
);
if ($inserted === 1) {
$created++;
dispatch(new \App\Jobs\EntraGroupSyncJob(
tenantId: $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: $slotKey,
));
} else {
if (! $opRun->wasRecentlyCreated) {
$skipped++;
continue;
}
$created++;
dispatch(new \App\Jobs\EntraGroupSyncJob(
tenantId: $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: $slotKey,
runId: null,
operationRun: $opRun,
));
}
$this->info(sprintf(

View File

@ -5,7 +5,6 @@
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
@ -13,7 +12,9 @@
use App\Models\RestoreRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class TenantpilotPurgeNonPersistentData extends Command
@ -33,7 +34,7 @@ class TenantpilotPurgeNonPersistentData extends Command
*
* @var string
*/
protected $description = 'Permanently delete non-persistent (regeneratable) tenant data like policies, backups, runs, and logs.';
protected $description = 'Permanently delete non-persistent tenant data like policies, backups, and runs while preserving durable audit logs.';
/**
* Execute the console command.
@ -80,10 +81,6 @@ public function handle(): int
}
DB::transaction(function () use ($tenant): void {
BackupScheduleRun::query()
->where('tenant_id', $tenant->id)
->delete();
BackupSchedule::query()
->where('tenant_id', $tenant->id)
->delete();
@ -92,10 +89,6 @@ public function handle(): int
->where('tenant_id', $tenant->id)
->delete();
AuditLog::query()
->where('tenant_id', $tenant->id)
->delete();
RestoreRun::withTrashed()
->where('tenant_id', $tenant->id)
->forceDelete();
@ -117,6 +110,8 @@ public function handle(): int
->delete();
});
$this->recordPurgeOperationRun($tenant, $counts);
$this->info('Purged.');
}
@ -150,10 +145,9 @@ private function resolveTenants()
private function countsForTenant(Tenant $tenant): array
{
return [
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
'audit_logs_retained' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(),
@ -161,4 +155,42 @@ private function countsForTenant(Tenant $tenant): array
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
];
}
/**
* @param array<string, int> $counts
*/
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
{
$deletedRows = Arr::except($counts, ['audit_logs_retained']);
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_purge',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', implode(':', [
(string) $tenant->id,
'backup_schedule_purge',
now()->toISOString(),
Str::uuid()->toString(),
])),
'summary_counts' => [
'total' => array_sum($deletedRows),
'processed' => array_sum($deletedRows),
'succeeded' => array_sum($deletedRows),
'failed' => 0,
],
'failure_summary' => [],
'context' => [
'source' => 'tenantpilot:purge-nonpersistent',
'deleted_rows' => $deletedRows,
'audit_logs_retained' => $counts['audit_logs_retained'] ?? 0,
],
'started_at' => now(),
'completed_at' => now(),
]);
}
}

View File

@ -2,11 +2,12 @@
namespace App\Console\Commands;
use App\Models\BackupScheduleRun;
use App\Models\BackupSchedule;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationRunOutcome;
use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -16,16 +17,18 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle(OperationRunService $operationRunService): int
{
public function handle(
OperationRunService $operationRunService,
OperationLifecycleReconciler $operationLifecycleReconciler,
): int {
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query()
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) {
@ -49,29 +52,18 @@ public function handle(OperationRunService $operationRunService): int
$failed = 0;
foreach ($query->cursor() as $operationRun) {
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
$backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
if (! is_numeric($backupScheduleRunId)) {
$skipped++;
continue;
}
$scheduleRun = BackupScheduleRun::query()
->whereKey((int) $backupScheduleRunId)
->where('tenant_id', $operationRun->tenant_id)
->first();
if (! $scheduleRun) {
if (! is_numeric($backupScheduleId)) {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule_run.not_found',
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
'code' => 'backup_schedule.missing_context',
'message' => 'Backup schedule context is missing from this operation run.',
],
],
);
@ -82,118 +74,40 @@ public function handle(OperationRunService $operationRunService): int
continue;
}
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
if (! $dryRun) {
$operationRunService->updateRun($operationRun, 'running', 'pending');
$schedule = BackupSchedule::query()
->whereKey((int) $backupScheduleId)
->where('tenant_id', (int) $operationRun->tenant_id)
->first();
if ($scheduleRun->started_at) {
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
}
if (! $schedule instanceof BackupSchedule) {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.not_found',
'message' => 'Backup schedule not found for this operation run.',
],
],
);
}
$failed++;
continue;
}
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
if ($change !== null) {
$reconciled++;
continue;
}
$outcome = match ($scheduleRun->status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
BackupScheduleRun::STATUS_CANCELED => 'failed',
default => 'failed',
};
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
$syncFailures = $summary['sync_failures'] ?? [];
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
$processed = $policiesBackedUp + $syncFailuresCount;
if ($policiesTotal > 0) {
$processed = min($policiesTotal, $processed);
}
$summaryCounts = array_filter([
'total' => $policiesTotal,
'processed' => $processed,
'succeeded' => $policiesBackedUp,
'failed' => $syncFailuresCount,
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
'items' => $policiesTotal,
], fn (mixed $value): bool => is_int($value) && $value !== 0);
$failures = [];
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
$failures[] = [
'code' => 'backup_schedule_run.cancelled',
'message' => 'Backup schedule run was cancelled.',
];
}
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
$failures[] = [
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
];
}
if (is_array($syncFailures)) {
foreach ($syncFailures as $failure) {
if (! is_array($failure)) {
continue;
}
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$errors = $failure['errors'] ?? null;
$firstErrorMessage = null;
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
$firstErrorMessage = $errors[0]['message'] ?? null;
}
$message = $status !== null
? "{$policyType}: Graph returned {$status}"
: "{$policyType}: Graph request failed";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$message .= ' - '.trim($firstErrorMessage);
}
$failures[] = [
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
'message' => RunFailureSanitizer::sanitizeMessage($message),
];
}
}
if (! $dryRun) {
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
]),
]);
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
$operationRun->forceFill([
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
])->save();
}
$reconciled++;
$skipped++;
}
$this->info(sprintf(

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\Operations\OperationLifecyclePolicy;
use Illuminate\Console\Command;
class TenantpilotReconcileOperationRuns extends Command
{
protected $signature = 'tenantpilot:operation-runs:reconcile
{--type=* : Limit reconciliation to one or more covered operation types}
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
{--workspace=* : Limit reconciliation to workspace ids}
{--limit=100 : Maximum number of active runs to inspect}
{--dry-run : Report the changes without writing them}';
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
public function handle(
OperationLifecycleReconciler $reconciler,
OperationLifecyclePolicy $policy,
): int {
$types = array_values(array_filter(
(array) $this->option('type'),
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
));
$workspaceIds = array_values(array_filter(
array_map(
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
(array) $this->option('workspace'),
),
static fn (int $workspaceId): bool => $workspaceId > 0,
));
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
$dryRun = (bool) $this->option('dry-run');
if ($types === []) {
$types = $policy->coveredTypeNames();
}
$result = $reconciler->reconcile([
'types' => $types,
'tenant_ids' => $tenantIds,
'workspace_ids' => $workspaceIds,
'limit' => max(1, (int) $this->option('limit')),
'dry_run' => $dryRun,
]);
$rows = collect($result['changes'] ?? [])
->map(static function (array $change): array {
return [
'Run' => (string) ($change['operation_run_id'] ?? '—'),
'Type' => (string) ($change['type'] ?? '—'),
'Reason' => (string) ($change['reason_code'] ?? '—'),
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
];
})
->values()
->all();
if ($rows !== []) {
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
}
$this->info(sprintf(
'Inspected %d run(s); reconciled %d; skipped %d.',
(int) ($result['candidates'] ?? 0),
(int) ($result['reconciled'] ?? 0),
(int) ($result['skipped'] ?? 0),
));
if ($dryRun) {
$this->comment('Dry-run: no changes written.');
}
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int, int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
if ($tenantIdentifiers === []) {
return [];
}
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()->forTenant($identifier)->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Services\Runbooks\RunbookReason;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotRunDeployRunbooks extends Command
{
protected $signature = 'tenantpilot:run-deploy-runbooks';
protected $description = 'Run deploy-time runbooks idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
try {
$runbookService->start(
scope: FindingsLifecycleBackfillScope::allTenants(),
initiator: null,
reason: new RunbookReason(
reasonCode: RunbookReason::CODE_DATA_REPAIR,
reasonText: 'Deploy hook automated runbooks',
),
source: 'deploy_hook',
);
$this->info('Deploy runbooks started (if needed).');
return self::SUCCESS;
} catch (ValidationException $e) {
$errors = $e->errors();
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
if ($skippable) {
$this->info('Deploy runbooks skipped (nothing to do or already running).');
return self::SUCCESS;
}
$this->error('Deploy runbooks blocked by validation errors.');
return self::FAILURE;
}
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Contracts\Hardening;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\Tenant;
interface WriteGateInterface
{
/**
* Evaluate whether a write operation is allowed for the given tenant.
*
* @throws ProviderAccessHardeningRequired when the operation is blocked
*/
public function evaluate(Tenant $tenant, string $operationType): void;
/**
* Check whether the gate would block a write operation for the given tenant.
*
* Non-throwing variant for UI disabled-state checks.
*/
public function wouldBlock(Tenant $tenant): bool;
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions\Hardening;
use RuntimeException;
class ProviderAccessHardeningRequired extends RuntimeException
{
public function __construct(
public readonly int $tenantId,
public readonly string $operationType,
public readonly string $reasonCode,
public readonly string $reasonMessage,
) {
parent::__construct($reasonMessage);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Onboarding;
use RuntimeException;
class OnboardingDraftConflictException extends RuntimeException
{
public function __construct(
public readonly int $draftId,
public readonly int $expectedVersion,
public readonly int $actualVersion,
string $message = 'This onboarding draft changed in another tab or session.',
) {
parent::__construct($message);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Onboarding;
use App\Support\Onboarding\OnboardingLifecycleState;
use RuntimeException;
class OnboardingDraftImmutableException extends RuntimeException
{
public function __construct(
public readonly int $draftId,
public readonly OnboardingLifecycleState $lifecycleState,
string $message = 'This onboarding draft is no longer editable.',
) {
parent::__construct($message);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use App\Services\Evidence\EvidenceResolutionResult;
use RuntimeException;
class ReviewPackEvidenceResolutionException extends RuntimeException
{
public function __construct(
public readonly EvidenceResolutionResult $result,
?string $message = null,
) {
parent::__construct($message ?? self::defaultMessage($result));
}
private static function defaultMessage(EvidenceResolutionResult $result): string
{
return match ($result->outcome) {
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
default => 'Evidence snapshot resolution failed.',
};
}
}

View File

@ -6,11 +6,26 @@
use BackedEnum;
use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum;
class InventoryCluster extends Cluster
{
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Items';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Filament\Clusters\Monitoring;
use BackedEnum;
use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum;
class AlertsCluster extends Cluster
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?int $navigationSort = 20;
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
public static function shouldRegisterNavigation(): bool
{
return Filament::getCurrentPanel()?->getId() === 'admin';
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait InteractsWithTenantOwnedRecords
{
protected static function tenantOwnedRelationshipName(): string
{
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
? static::$tenantOwnershipRelationshipName
: null;
return is_string($relationshipName) && $relationshipName !== ''
? $relationshipName
: 'tenant';
}
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
{
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
return static::resolveTenantContextForCurrentPanel();
}
if (method_exists(static::class, 'panelTenantContext')) {
return static::panelTenantContext();
}
return null;
}
public static function getTenantOwnedEloquentQuery(): Builder
{
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
}
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
{
return app(TenantOwnedQueryScope::class)->apply(
$query,
$tenant ?? static::resolveTenantContextForTenantOwnedRecords(),
static::tenantOwnedRelationshipName(),
);
}
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
{
$scopedQuery = static::scopeTenantOwnedQuery(
$query ?? parent::getEloquentQuery(),
$tenant,
);
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
}
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
{
$scopedQuery = static::scopeTenantOwnedQuery(
$query ?? parent::getEloquentQuery(),
$tenant,
);
return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record);
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use Filament\Facades\Filament;
use RuntimeException;
trait ResolvesPanelTenantContext
{
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant ? $tenant : null;
}
public static function panelTenantContext(): ?Tenant
{
return static::resolveTenantContextForCurrentPanel();
}
public static function trustedPanelTenantContext(): ?Tenant
{
return static::panelTenantContext();
}
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
throw new RuntimeException('No tenant context selected.');
}
return $tenant;
}
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
{
return static::resolveTenantContextForCurrentPanelOrFail();
}
}

View File

@ -4,6 +4,9 @@
namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@ -19,6 +22,10 @@ public static function getGlobalSearchEloquentQuery(): Builder
{
$query = static::getModel()::query();
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
return $query->whereRaw('1 = 0');
}
if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel();
@ -27,7 +34,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
}
}
$tenant = Filament::getTenant();
$tenant = static::resolveGlobalSearchTenant();
if (! $tenant instanceof Model) {
return $query->whereRaw('1 = 0');
@ -41,4 +48,17 @@ public static function getGlobalSearchEloquentQuery(): Builder
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
}
protected static function resolveGlobalSearchTenant(): ?Model
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Filament::getTenant();
return $tenant instanceof Model ? $tenant : null;
}
}

View File

@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BaselineCompareLanding extends Page
{
use ResolvesPanelTenantContext;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baseline Compare';
protected static ?int $navigationSort = 10;
protected static ?string $title = 'Baseline Compare';
protected string $view = 'filament.pages.baseline-compare-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $reasonCode = null;
public ?string $reasonMessage = null;
public ?string $profileName = null;
public ?int $profileId = null;
public ?int $snapshotId = null;
public ?int $duplicateNamePoliciesCount = null;
public ?int $duplicateNameSubjectsCount = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
/** @var array<string, int>|null */
public ?array $severityCounts = null;
public ?string $lastComparedAt = null;
public ?string $lastComparedIso = null;
public ?string $failureReason = null;
public ?string $coverageStatus = null;
public ?int $uncoveredTypesCount = null;
/** @var list<string>|null */
public ?array $uncoveredTypes = null;
public ?string $fidelity = null;
public ?int $evidenceGapsCount = null;
/** @var array<string, int>|null */
public ?array $evidenceGapsTopReasons = null;
/** @var array<string, mixed>|null */
public ?array $evidenceGapSummary = null;
/** @var list<array<string, mixed>>|null */
public ?array $evidenceGapBuckets = null;
/** @var array<string, mixed>|null */
public ?array $baselineCompareDiagnostics = null;
/** @var array<string, int>|null */
public ?array $rbacRoleDefinitionSummary = null;
/** @var array<string, mixed>|null */
public ?array $operatorExplanation = null;
/** @var array<string, mixed>|null */
public ?array $summaryAssessment = null;
public static function canAccess(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$this->refreshStats();
}
public function refreshStats(): void
{
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
$this->state = $stats->state;
$this->message = $stats->message;
$this->profileName = $stats->profileName;
$this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId;
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
$this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
$this->lastComparedAt = $stats->lastComparedHuman;
$this->lastComparedIso = $stats->lastComparedIso;
$this->failureReason = $stats->failureReason;
$this->reasonCode = $stats->reasonCode;
$this->reasonMessage = $stats->reasonMessage;
$this->coverageStatus = $stats->coverageStatus;
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
$this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null;
$this->fidelity = $stats->fidelity;
$this->evidenceGapsCount = $stats->evidenceGapsCount;
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
? $stats->evidenceGapDetails['summary']
: null;
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
? $stats->evidenceGapDetails['buckets']
: null;
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
? $stats->baselineCompareDiagnostics
: null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
}
/**
* Computed view data exposed to the Blade template.
*
* Moves presentational logic out of Blade `@php` blocks so the
* template only receives ready-to-render values.
*
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count']
: (int) ($this->evidenceGapsCount ?? 0);
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
? (string) $evidenceGapSummary['detail_state']
: 'no_gaps';
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
$evidenceGapsSummary = null;
$evidenceGapsTooltip = null;
if ($hasEvidenceGaps) {
$parts = array_map(
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
BaselineCompareEvidenceGapDetails::topReasons(
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
5,
),
);
if ($parts !== []) {
$evidenceGapsSummary = implode(', ', $parts);
$evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]);
}
}
// Derive the colour class for the findings-count stat card.
// Only show danger-red when high-severity findings exist;
// use warning-orange for low/medium-only, and success-green for zero.
$findingsColorClass = $this->resolveFindingsColorClass($hasWarnings);
// "Why no findings" explanation when count is zero.
$whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null;
$whyNoFindingsFallback = ! $hasWarnings
? __('baseline-compare.no_findings_all_clear')
: ($hasCoverageWarnings
? __('baseline-compare.no_findings_coverage_warnings')
: ($hasEvidenceGaps
? __('baseline-compare.no_findings_evidence_gaps')
: __('baseline-compare.no_findings_default')));
$whyNoFindingsColor = $hasWarnings
? 'text-warning-600 dark:text-warning-400'
: 'text-success-600 dark:text-success-400';
if ($this->reasonCode === 'no_subjects_in_scope') {
$whyNoFindingsColor = 'text-gray-600 dark:text-gray-400';
}
return [
'hasCoverageWarnings' => $hasCoverageWarnings,
'evidenceGapsCountValue' => $evidenceGapsCountValue,
'hasEvidenceGaps' => $hasEvidenceGaps,
'hasWarnings' => $hasWarnings,
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
'evidenceGapDetailState' => $evidenceGapDetailState,
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
'evidenceGapsSummary' => $evidenceGapsSummary,
'evidenceGapsTooltip' => $evidenceGapsTooltip,
'findingsColorClass' => $findingsColorClass,
'whyNoFindingsMessage' => $whyNoFindingsMessage,
'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor,
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
];
}
/**
* Resolve the Tailwind colour class for the Total Findings stat.
*
* - Red (danger) only when high-severity findings exist
* - Orange (warning) for medium/low-only findings or when warnings present
* - Green (success) when fully clear
*/
private function resolveFindingsColorClass(bool $hasWarnings): string
{
$count = (int) ($this->findingsCount ?? 0);
if ($count === 0) {
return $hasWarnings
? 'text-warning-600 dark:text-warning-400'
: 'text-success-600 dark:text-success-400';
}
$hasHigh = ($this->severityCounts['high'] ?? 0) > 0;
return $hasHigh
? 'text-danger-600 dark:text-danger-400'
: 'text-warning-600 dark:text-warning-400';
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->compareNowAction(),
];
}
private function compareNowAction(): Action
{
$isFullContent = false;
if (is_int($this->profileId) && $this->profileId > 0) {
$profile = \App\Models\BaselineProfile::query()->find($this->profileId);
$mode = $profile?->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
: (is_string($profile?->capture_mode) ? BaselineCaptureMode::tryFrom($profile->capture_mode) : null);
$isFullContent = $mode === BaselineCaptureMode::FullContent;
}
$label = $isFullContent ? 'Compare now (full content)' : 'Compare now';
$modalDescription = $isFullContent
? 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.'
: 'This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.';
$action = Action::make('compareNow')
->label($label)
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading($label)
->modalDescription($modalDescription)
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
Notification::make()->title('Not authenticated')->danger()->send();
return;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
return;
}
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$message = match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.$reasonCode,
};
Notification::make()
->title('Cannot start comparison')
->body($message)
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if ($run instanceof OperationRun) {
$this->operationRunId = (int) $run->getKey();
}
$this->state = 'comparing';
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
->actions($run instanceof OperationRun ? [
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $tenant)),
] : [])
->send();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->preserveDisabled()
->apply();
}
public function getFindingsUrl(): ?string
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl('index', tenant: $tenant);
}
public function getRunUrl(): ?string
{
if ($this->operationRunId === null) {
return null;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return OperationRunLinks::view($this->operationRunId, $tenant);
}
}

View File

@ -7,6 +7,10 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Pages\Page;
@ -27,6 +31,17 @@ class ChooseTenant extends Page
protected string $view = 'filament.pages.choose-tenant';
/**
* Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
/**
* @return Collection<int, Tenant>
*/
@ -41,10 +56,10 @@ public function getTenants(): Collection
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
if ($tenants instanceof Collection) {
return $tenants;
return app(TenantOperabilityService::class)->filterSelectable($tenants);
}
return collect($tenants);
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
}
public function selectTenant(int $tenantId): void
@ -55,10 +70,35 @@ public function selectTenant(int $tenantId): void
abort(403);
}
$tenant = Tenant::query()
->where('status', 'active')
->whereKey($tenantId)
->first();
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId(request());
$tenant = null;
if ($workspaceId === null) {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if ($tenant instanceof Tenant) {
$workspace = $tenant->workspace;
if ($workspace !== null && $user->canAccessTenant($tenant)) {
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
$workspaceId = (int) $workspace->getKey();
}
}
}
if ($workspaceId === null) {
$this->redirect(route('filament.admin.pages.choose-workspace'));
return;
}
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
$tenant = Tenant::query()
->where('workspace_id', $workspaceId)
->whereKey($tenantId)
->first();
}
if (! $tenant instanceof Tenant) {
abort(404);
@ -68,13 +108,32 @@ public function selectTenant(int $tenantId): void
abort(404);
}
$outcome = app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::SelectorEligibility,
actor: $user,
workspaceId: $workspaceId,
lane: TenantInteractionLane::StandardActiveOperating,
);
if (! $outcome->allowed) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
abort(404);
}
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromTenant($tenant);
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {

View File

@ -7,10 +7,11 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
@ -30,33 +31,18 @@ class ChooseWorkspace extends Page
protected string $view = 'filament.pages.choose-workspace';
/**
* @return array<Action>
* Workspace roles keyed by workspace_id.
*
* @var array<int, string>
*/
public array $workspaceRoles = [];
/**
* @return array<\Filament\Actions\Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('createWorkspace')
->label('Create workspace')
->modalHeading('Create workspace')
->visible(function (): bool {
$user = auth()->user();
return $user instanceof User
&& $user->can('create', Workspace::class);
})
->form([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->helperText('Optional. Used in URLs if set.')
->maxLength(255)
->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug'])
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
])
->action(fn (array $data) => $this->createWorkspace($data)),
];
return [];
}
/**
@ -70,15 +56,28 @@ public function getWorkspaces(): Collection
return Workspace::query()->whereRaw('1 = 0')->get();
}
return Workspace::query()
$workspaces = Workspace::query()
->whereIn('id', function ($query) use ($user): void {
$query->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->withCount(['tenants' => function ($query): void {
$query->where('status', 'active');
}])
->orderBy('name')
->get();
// Build roles map from memberships.
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->whereIn('workspace_id', $workspaces->pluck('id'))
->pluck('role', 'workspace_id');
$this->workspaceRoles = $memberships->mapWithKeys(fn ($role, $id) => [(int) $id => (string) $role])->all();
return $workspaces;
}
public function selectWorkspace(int $workspaceId): void
@ -105,11 +104,37 @@ public function selectWorkspace(int $workspaceId): void
abort(404);
}
$prevWorkspaceId = $context->currentWorkspaceId(request());
$context->setCurrentWorkspace($workspace, $user, request());
// Audit: manual workspace selection.
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSelected->value,
context: [
'metadata' => [
'method' => 'manual',
'reason' => 'chooser',
'prev_workspace_id' => $prevWorkspaceId,
],
],
actor: $user,
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$this->redirect($redirectTarget);
}
/**
@ -147,41 +172,11 @@ public function createWorkspace(array $data): void
$intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
}
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
private function redirectAfterWorkspaceSelected(User $user): string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
if ($workspaceId === null) {
return self::getUrl();
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return self::getUrl();
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');
$tenantCount = (int) $tenantsQuery->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
}
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
}
}
return ChooseTenant::getUrl();
$this->redirect($redirectTarget);
}
}

View File

@ -1,284 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class DriftLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
protected static string|UnitEnum|null $navigationGroup = 'Drift';
protected static ?string $navigationLabel = 'Drift';
protected string $view = 'filament.pages.drift-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $scopeKey = null;
public ?int $baselineRunId = null;
public ?int $currentRunId = null;
public ?string $baselineFinishedAt = null;
public ?string $currentFinishedAt = null;
public ?int $operationRunId = null;
/** @var array<string, int>|null */
public ?array $statusCounts = null;
public static function canAccess(): bool
{
return FindingResource::canAccess();
}
public function mount(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
$latestSuccessful = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('status', InventorySyncRun::STATUS_SUCCESS)
->whereNotNull('finished_at')
->orderByDesc('finished_at')
->first();
if (! $latestSuccessful instanceof InventorySyncRun) {
$this->state = 'blocked';
$this->message = 'No successful inventory runs found yet.';
return;
}
$scopeKey = (string) $latestSuccessful->selection_hash;
$this->scopeKey = $scopeKey;
$selector = app(DriftRunSelector::class);
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
if ($comparison === null) {
$this->state = 'blocked';
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
return;
}
$baseline = $comparison['baseline'];
$current = $comparison['current'];
$this->baselineRunId = (int) $baseline->getKey();
$this->currentRunId = (int) $current->getKey();
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
$existingOperationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'drift.generate')
->where('context->scope_key', $scopeKey)
->where('context->baseline_run_id', (int) $baseline->getKey())
->where('context->current_run_id', (int) $current->getKey())
->latest('id')
->first();
if ($existingOperationRun instanceof OperationRun) {
$this->operationRunId = (int) $existingOperationRun->getKey();
}
$exists = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey())
->exists();
if ($exists) {
$this->state = 'ready';
$newCount = (int) Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey)
->where('baseline_run_id', $baseline->getKey())
->where('current_run_id', $current->getKey())
->where('status', Finding::STATUS_NEW)
->count();
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
return;
}
$existingOperationRun?->refresh();
if ($existingOperationRun instanceof OperationRun
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
) {
$this->state = 'generating';
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
if ($existingOperationRun instanceof OperationRun
&& $existingOperationRun->status === 'completed'
) {
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
$created = (int) ($counts['created'] ?? 0);
if ($existingOperationRun->outcome === 'failed') {
$this->state = 'error';
$this->message = 'Drift generation failed for this comparison. See the run for details.';
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
if ($created === 0) {
$this->state = 'ready';
$this->statusCounts = [Finding::STATUS_NEW => 0];
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
$this->operationRunId = (int) $existingOperationRun->getKey();
return;
}
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
$this->state = 'blocked';
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromQuery([
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->enqueueBulkOperation(
tenant: $tenant,
type: 'drift.generate',
targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'scope_key' => $scopeKey,
'baseline_run_id' => (int) $baseline->getKey(),
'current_run_id' => (int) $current->getKey(),
],
emitQueuedNotification: false,
);
$this->operationRunId = (int) $opRun->getKey();
$this->state = 'generating';
if (! $opRun->wasRecentlyCreated) {
Notification::make()
->title('Drift generation already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}
public function getFindingsUrl(): string
{
return FindingResource::getUrl('index', tenant: Tenant::current());
}
public function getBaselineRunUrl(): ?string
{
if (! is_int($this->baselineRunId)) {
return null;
}
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
}
public function getCurrentRunUrl(): ?string
{
if (! is_int($this->currentRunId)) {
return null;
}
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
}
public function getOperationRunUrl(): ?string
{
if (! is_int($this->operationRunId)) {
return null;
}
return OperationRunLinks::view($this->operationRunId, Tenant::current());
}
}

View File

@ -1,17 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Support\Enums\FontFamily;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum;
class InventoryCoverage extends Page
class InventoryCoverage extends Page implements HasTable
{
use InteractsWithTable;
use ResolvesPanelTenantContext;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
protected static ?int $navigationSort = 3;
@ -24,6 +52,36 @@ class InventoryCoverage extends Page
protected string $view = 'filament.pages.inventory-coverage';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canAccess(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
protected function getHeaderWidgets(): array
{
return [
@ -31,35 +89,364 @@ protected function getHeaderWidgets(): array
];
}
/**
* @var array<int, array<string, mixed>>
*/
public array $supportedPolicyTypes = [];
public function table(Table $table): Table
{
return $table
->searchable()
->searchPlaceholder('Search by policy type or label')
->defaultSort('label')
->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->filterRows(
rows: $this->coverageRows(),
search: $search,
filters: $filters,
);
$rows = $this->sortRows(
rows: $rows,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
);
return $this->paginateRows(
rows: $rows,
page: $page,
recordsPerPage: $recordsPerPage,
);
})
->columns([
TextColumn::make('type')
->label('Type')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap(),
TextColumn::make('label')
->label('Label')
->sortable()
->badge()
->formatStateUsing(function (?string $state, array $record): string {
return TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
)->label;
})
->color(function (?string $state, array $record): string {
return TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
)->color;
})
->icon(function (?string $state, array $record): ?string {
return TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
)->icon;
})
->iconColor(function (?string $state, array $record): ?string {
$spec = TagBadgeCatalog::spec(
TagBadgeDomain::PolicyType,
$record['type'] ?? $state,
);
return $spec->iconColor ?? $spec->color;
})
->wrap(),
TextColumn::make('risk')
->label('Risk')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
TextColumn::make('restore')
->label('Restore')
->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
: 'Not provided';
})
->color(function (?string $state): string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->color
: 'gray';
})
->icon(function (?string $state): ?string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->icon
: 'heroicon-m-minus-circle';
})
->iconColor(function (?string $state): ?string {
if (! filled($state)) {
return 'gray';
}
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
return $spec->iconColor ?? $spec->color;
}),
TextColumn::make('category')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
->toggleable()
->wrap(),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(),
IconColumn::make('dependencies')
->label('Dependencies')
->boolean()
->trueIcon('heroicon-m-check-circle')
->falseIcon('heroicon-m-minus-circle')
->trueColor('success')
->falseColor('gray')
->alignCenter()
->toggleable(),
])
->filters($this->tableFilters())
->emptyStateHeading('No coverage entries match this view')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->resetTable();
}),
])
->actions([])
->bulkActions([]);
}
/**
* @var array<int, array<string, mixed>>
* @return array<int, SelectFilter>
*/
public array $foundationTypes = [];
protected function tableFilters(): array
{
$filters = [
SelectFilter::make('category')
->label('Category')
->options($this->categoryFilterOptions()),
];
public function mount(): void
if ($this->restoreFilterOptions() !== []) {
$filters[] = SelectFilter::make('restore')
->label('Restore mode')
->options($this->restoreFilterOptions());
}
return $filters;
}
/**
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function coverageRows(): Collection
{
$resolver = app(CoverageCapabilitiesResolver::class);
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
->map(function (array $row) use ($resolver): array {
$supported = $this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::supported(),
segment: 'policy',
sourceOrderOffset: 0,
resolver: $resolver,
);
return $supported->merge($this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::foundations(),
segment: 'foundation',
sourceOrderOffset: $supported->count(),
resolver: $resolver,
));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function mapCoverageRows(
array $rows,
string $segment,
int $sourceOrderOffset,
CoverageCapabilitiesResolver $resolver
): Collection {
return collect($rows)
->values()
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
$type = (string) ($row['type'] ?? '');
return array_merge($row, [
'dependencies' => $type !== '' && $resolver->supportsDependencies($type),
]);
if ($type === '') {
return [];
}
$key = "{$segment}:{$type}";
$restore = $row['restore'] ?? null;
$risk = $row['risk'] ?? 'n/a';
return [
$key => [
'__key' => $key,
'key' => $key,
'segment' => $segment,
'type' => $type,
'label' => (string) ($row['label'] ?? $type),
'category' => (string) ($row['category'] ?? 'Other'),
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
'restore' => is_string($restore) ? $restore : null,
'risk' => is_string($risk) ? $risk : 'n/a',
'source_order' => $sourceOrderOffset + $index,
],
];
});
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @param array<string, mixed> $filters
* @return Collection<string, array<string, mixed>>
*/
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$category = $filters['category']['value'] ?? null;
$restore = $filters['restore']['value'] ?? null;
return $rows
->when(
$normalizedSearch !== '',
function (Collection $rows) use ($normalizedSearch): Collection {
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
return str_contains(Str::lower((string) $row['type']), $normalizedSearch)
|| str_contains(Str::lower((string) $row['label']), $normalizedSearch);
});
},
)
->when(
filled($category),
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
)
->when(
filled($restore),
fn (Collection $rows): Collection => $rows->where('restore', (string) $restore),
);
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
if ($sortColumn === null) {
return $rows->sortBy('source_order');
}
$records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
if ($comparison === 0) {
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
}
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
protected function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
/**
* @return array<string, string>
*/
protected function categoryFilterOptions(): array
{
return $this->coverageRows()
->pluck('category')
->filter(fn (mixed $category): bool => is_string($category) && $category !== '')
->unique()
->sort()
->mapWithKeys(function (string $category): array {
return [
$category => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $category)->label,
];
})
->all();
}
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
->map(function (array $row): array {
return array_merge($row, [
'dependencies' => false,
]);
/**
* @return array<string, string>
*/
protected function restoreFilterOptions(): array
{
return $this->coverageRows()
->pluck('restore')
->filter(fn (mixed $restore): bool => is_string($restore) && $restore !== '')
->unique()
->sort()
->mapWithKeys(function (string $restore): array {
return [
$restore => BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $restore)->label,
];
})
->all();
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class InventoryLanding extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Overview';
protected static ?string $cluster = InventoryCluster::class;
protected string $view = 'filament.pages.inventory-landing';
public function mount(): void
{
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
}
protected function getHeaderWidgets(): array
{
return [
InventoryKpiHeader::class,
];
}
}

View File

@ -4,25 +4,84 @@
namespace App\Filament\Pages\Monitoring;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Widgets\Alerts\AlertsKpiHeader;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use UnitEnum;
class Alerts extends Page
{
protected static bool $isDiscovered = false;
protected static ?string $cluster = AlertsCluster::class;
protected static bool $shouldRegisterNavigation = false;
protected static ?int $navigationSort = 20;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alerts';
protected static ?string $navigationLabel = 'Overview';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static ?string $slug = 'alerts';
protected static ?string $slug = 'overview';
protected static ?string $title = 'Alerts';
protected string $view = 'filament.pages.monitoring.alerts';
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
}
protected function getHeaderWidgets(): array
{
return [
AlertsKpiHeader::class,
];
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
}

View File

@ -4,12 +4,47 @@
namespace App\Filament\Pages\Monitoring;
use App\Models\AuditLog as AuditLogModel;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class AuditLog extends Page
class AuditLog extends Page implements HasTable
{
use InteractsWithTable;
public ?int $selectedAuditLogId = null;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
@ -25,4 +60,342 @@ class AuditLog extends Page
protected static ?string $title = 'Audit Log';
protected string $view = 'filament.pages.monitoring.audit-log';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the Monitoring scope visible and expose selected-event detail actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Audit history is immutable and intentionally omits bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table exposes a clear-filters CTA when no audit events match the current view.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected-event detail keeps close-inspection and related-navigation actions at the page header.');
}
public function mount(): void
{
$this->authorizePageAccess();
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
$this->mountInteractsWithTable();
if ($requestedEventId !== null) {
$this->resolveAuditLog($requestedEventId);
$this->selectedAuditLogId = $requestedEventId;
$this->mountTableAction('inspect', (string) $requestedEventId);
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_audit_log',
returnActionName: 'operate_hub_return_audit_log',
);
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->auditBaseQuery())
->defaultSort('recorded_at', 'desc')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
TextColumn::make('outcome')
->label('Outcome')
->badge()
->getStateUsing(fn (AuditLogModel $record): string => $record->normalizedOutcome()->value)
->formatStateUsing(fn (string $state): string => BadgeRenderer::label(BadgeDomain::AuditOutcome)($state))
->color(fn (string $state): string => BadgeRenderer::color(BadgeDomain::AuditOutcome)($state))
->icon(fn (string $state): ?string => BadgeRenderer::icon(BadgeDomain::AuditOutcome)($state))
->iconColor(fn (string $state): ?string => BadgeRenderer::iconColor(BadgeDomain::AuditOutcome)($state)),
TextColumn::make('summary')
->label('Event')
->getStateUsing(fn (AuditLogModel $record): string => $record->summaryText())
->description(fn (AuditLogModel $record): string => AuditActionId::labelFor((string) $record->action))
->searchable()
->wrap(),
TextColumn::make('actor_label')
->label('Actor')
->getStateUsing(fn (AuditLogModel $record): string => $record->actorDisplayLabel())
->description(fn (AuditLogModel $record): string => BadgeRenderer::label(BadgeDomain::AuditActorType)($record->actorSnapshot()->type->value))
->searchable(),
TextColumn::make('target_label')
->label('Target')
->getStateUsing(fn (AuditLogModel $record): string => $record->targetDisplayLabel() ?? 'No target snapshot')
->searchable()
->toggleable(),
TextColumn::make('tenant.name')
->label('Tenant')
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
->toggleable(),
TextColumn::make('recorded_at')
->label('Recorded')
->since()
->sortable(),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->searchable(),
SelectFilter::make('action')
->label('Event type')
->options(fn (): array => $this->actionFilterOptions())
->searchable(),
SelectFilter::make('outcome')
->label('Outcome')
->options(FilterOptionCatalog::auditOutcomes()),
SelectFilter::make('actor_label')
->label('Actor')
->options(fn (): array => $this->actorFilterOptions())
->searchable(),
SelectFilter::make('resource_type')
->label('Target type')
->options(fn (): array => $this->targetTypeFilterOptions()),
FilterPresets::dateRange('recorded_at', 'Recorded', 'recorded_at'),
])
->actions([
Action::make('inspect')
->label('Inspect event')
->icon('heroicon-o-eye')
->color('gray')
->before(function (AuditLogModel $record): void {
$this->selectedAuditLogId = (int) $record->getKey();
})
->slideOver()
->stickyModalHeader()
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
'selectedAudit' => $record,
'selectedAuditLink' => $this->auditTargetLink($record),
])),
])
->bulkActions([])
->emptyStateHeading('No audit events match this view')
->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.')
->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->resetTable();
}),
]);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! $user instanceof User || ! is_numeric($workspaceId)) {
return $this->authorizedTenants = [];
}
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->filter(static fn (Tenant $tenant): bool => (int) $tenant->workspace_id === (int) $workspaceId)
->filter(static fn (Tenant $tenant): bool => $tenant->isActive())
->keyBy(fn (Tenant $tenant): int => (int) $tenant->getKey())
->all();
return $this->authorizedTenants = $tenants;
}
private function authorizePageAccess(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$workspace = is_numeric($workspaceId) ? Workspace::query()->whereKey((int) $workspaceId)->first() : null;
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
if (! $resolver->can($user, $workspace, Capabilities::AUDIT_VIEW)) {
abort(403);
}
}
private function auditBaseQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$authorizedTenantIds = array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->authorizedTenants(),
);
return AuditLogModel::query()
->with(['tenant', 'workspace', 'operationRun'])
->forWorkspace((int) $workspaceId)
->where(function (Builder $query) use ($authorizedTenantIds): void {
$query->whereNull('tenant_id');
if ($authorizedTenantIds !== []) {
$query->orWhereIn('tenant_id', $authorizedTenantIds);
}
})
->latestFirst();
}
private function resolveAuditLog(int $auditLogId): AuditLogModel
{
$record = $this->auditBaseQuery()
->whereKey($auditLogId)
->first();
if (! $record instanceof AuditLogModel) {
throw new NotFoundHttpException;
}
return $record;
}
public function selectedAuditRecord(): ?AuditLogModel
{
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
return null;
}
try {
return $this->resolveAuditLog($this->selectedAuditLogId);
} catch (NotFoundHttpException) {
return null;
}
}
/**
* @return array{label: string, url: string}|null
*/
public function selectedAuditTargetLink(): ?array
{
$record = $this->selectedAuditRecord();
if (! $record instanceof AuditLogModel) {
return null;
}
return $this->auditTargetLink($record);
}
/**
* @return array{label: string, url: string}|null
*/
private function auditTargetLink(AuditLogModel $record): ?array
{
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->getFilamentName(),
])
->all();
}
private function defaultTenantFilter(): ?string
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
return array_key_exists((int) $activeTenant->getKey(), $this->authorizedTenants())
? (string) $activeTenant->getKey()
: null;
}
/**
* @return array<string, string>
*/
private function actionFilterOptions(): array
{
$values = (clone $this->auditBaseQuery())
->reorder()
->select('action')
->distinct()
->orderBy('action')
->pluck('action')
->all();
return FilterOptionCatalog::auditActions($values);
}
/**
* @return array<string, string>
*/
private function actorFilterOptions(): array
{
return (clone $this->auditBaseQuery())
->reorder()
->whereNotNull('actor_label')
->select('actor_label')
->distinct()
->orderBy('actor_label')
->pluck('actor_label', 'actor_label')
->all();
}
/**
* @return array<string, string>
*/
private function targetTypeFilterOptions(): array
{
$values = (clone $this->auditBaseQuery())
->reorder()
->whereNotNull('resource_type')
->select('resource_type')
->distinct()
->orderBy('resource_type')
->pluck('resource_type')
->all();
return FilterOptionCatalog::auditTargetTypes($values);
}
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Illuminate\Auth\AuthenticationException;
use UnitEnum;
class EvidenceOverview extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $title = 'Evidence Overview';
protected string $view = 'filament.pages.monitoring.evidence-overview';
/**
* @var list<array<string, mixed>>
*/
public array $rows = [];
public ?int $tenantFilter = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
}
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
throw new AuthenticationException;
}
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
$workspaceId = (int) $workspace->getKey();
$accessibleTenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
->values();
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
$query = EvidenceSnapshot::query()
->with('tenant')
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $tenantIds)
->where('status', 'active')
->latest('generated_at');
if ($this->tenantFilter !== null) {
$query->where('tenant_id', $this->tenantFilter);
}
$snapshots = $query->get()->unique('tenant_id')->values();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => (int) $snapshot->tenant_id,
'snapshot_id' => (int) $snapshot->getKey(),
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $truth->nextStepText(),
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
})->all();
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('clear_filters')
->label('Clear filters')
->color('gray')
->visible(fn (): bool => $this->tenantFilter !== null)
->url(route('admin.evidence.overview')),
];
}
}

View File

@ -0,0 +1,503 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingExceptionService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class FindingExceptionsQueue extends Page implements HasTable
{
use InteractsWithTable;
public ?int $selectedFindingExceptionId = null;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Finding exceptions';
protected static ?string $slug = 'finding-exceptions/queue';
protected static ?string $title = 'Finding Exceptions Queue';
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
}
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
public function mount(): void
{
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
$this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter();
if ($this->selectedFindingExceptionId !== null) {
$this->selectedFindingException();
}
}
protected function getHeaderActions(): array
{
$actions = app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_finding_exceptions',
returnActionName: 'operate_hub_return_finding_exceptions',
);
$actions[] = Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveQueueFilters())
->action(function (): void {
$this->removeTableFilter('tenant_id');
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->resetTable();
});
$actions[] = Action::make('view_tenant_register')
->label('View tenant register')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
->url(function (): ?string {
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
});
$actions[] = Action::make('clear_selected_exception')
->label('Close details')
->color('gray')
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->action(function (): void {
$this->selectedFindingExceptionId = null;
});
$actions[] = Action::make('open_selected_exception')
->label('Open tenant detail')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->url(fn (): ?string => $this->selectedExceptionUrl());
$actions[] = Action::make('open_selected_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->url(fn (): ?string => $this->selectedFindingUrl());
$actions[] = Action::make('approve_selected_exception')
->label('Approve exception')
->color('success')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->form([
DateTimePicker::make('effective_from')
->label('Effective from')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->required()
->seconds(false),
Textarea::make('approval_reason')
->label('Approval reason')
->rows(3)
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->approve($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
->success()
->send();
});
$actions[] = Action::make('reject_selected_exception')
->label('Reject exception')
->color('danger')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->form([
Textarea::make('rejection_reason')
->label('Rejection reason')
->rows(3)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->reject($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
->success()
->send();
});
return $actions;
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->queueBaseQuery())
->defaultSort('requested_at', 'asc')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
TextColumn::make('current_validity_state')
->label('Validity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
TextColumn::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
->searchable(),
TextColumn::make('requester.name')
->label('Requested by')
->placeholder('—'),
TextColumn::make('owner.name')
->label('Owner')
->placeholder('—'),
TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->placeholder('—')
->sortable(),
TextColumn::make('expires_at')
->label('Expires')
->dateTime()
->placeholder('—')
->sortable(),
TextColumn::make('requested_at')
->label('Requested')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
SelectFilter::make('status')
->options(FilterOptionCatalog::findingExceptionStatuses()),
SelectFilter::make('current_validity_state')
->label('Validity')
->options(FilterOptionCatalog::findingExceptionValidityStates()),
])
->actions([
Action::make('inspect_exception')
->label('Inspect exception')
->icon('heroicon-o-eye')
->color('gray')
->action(function (FindingException $record): void {
$this->selectedFindingExceptionId = (int) $record->getKey();
}),
])
->bulkActions([])
->emptyStateHeading('No exceptions match this queue')
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
->emptyStateIcon('heroicon-o-shield-check')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->removeTableFilter('tenant_id');
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->resetTable();
}),
]);
}
public function selectedFindingException(): ?FindingException
{
if (! is_int($this->selectedFindingExceptionId)) {
return null;
}
$record = $this->queueBaseQuery()
->whereKey($this->selectedFindingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
}
public function selectedExceptionUrl(): ?string
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException || ! $record->tenant) {
return null;
}
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
}
public function selectedFindingUrl(): ?string
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
if (! $user instanceof User) {
return $this->authorizedTenants = [];
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return $this->authorizedTenants = [];
}
$tenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get();
return $this->authorizedTenants = $tenants
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
->values()
->all();
}
private function queueBaseQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantIds = array_values(array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->authorizedTenants(),
));
return FindingException::query()
->with([
'tenant',
'requester',
'owner',
'approver',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
'decisions.actor',
'evidenceReferences',
])
->where('workspace_id', (int) $workspaceId)
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return Collection::make($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function hasActiveQueueFilters(): bool
{
return $this->currentTenantFilterId() !== null
|| is_string(data_get($this->tableFilters, 'status.value'))
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
}
}

View File

@ -7,10 +7,17 @@
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
@ -27,6 +34,11 @@ class Operations extends Page implements HasForms, HasTable
public string $activeTab = 'all';
/**
* @var array<string, mixed>|null
*/
public ?array $navigationContextPayload = null;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
@ -40,6 +52,14 @@ class Operations extends Page implements HasForms, HasTable
public function mount(): void
{
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['type', 'initiator_name'],
request(),
);
$this->mountInteractsWithTable();
}
@ -50,6 +70,66 @@ protected function getHeaderWidgets(): array
];
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$navigationContext = $this->navigationContext();
$actions = [
Action::make('operate_hub_scope_operations')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_operations')
->label($navigationContext->backLinkLabel)
->icon('heroicon-o-arrow-left')
->color('gray')
->url($navigationContext->backLinkUrl);
} elseif ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
}
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_show_all_tenants')
->label('Show all tenants')
->color('gray')
->action(function (): void {
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilter('tenant_id');
$this->redirect('/admin/operations');
});
}
return $actions;
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
return CanonicalNavigationContext::fromRequest($request);
}
public function updatedActiveTab(): void
{
$this->resetPage();
@ -60,6 +140,11 @@ public function table(Table $table): Table
return OperationRunResource::table($table)
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
$query = OperationRun::query()
->with('user')
@ -71,12 +156,78 @@ public function table(Table $table): Table
->when(
! $workspaceId,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
is_numeric($tenantFilter),
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
);
return $this->applyActiveTab($query);
});
}
/**
* @return array{likely_stale:int,reconciled:int}
*/
public function lifecycleVisibilitySummary(): array
{
$baseQuery = $this->scopedSummaryQuery();
if (! $baseQuery instanceof Builder) {
return [
'likely_stale' => 0,
'reconciled' => 0,
];
}
$reconciled = (clone $baseQuery)
->whereNotNull('context->reconciliation->reconciled_at')
->count();
$policy = app(OperationLifecyclePolicy::class);
$likelyStale = (clone $baseQuery)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->where(function (Builder $query) use ($policy): void {
foreach ($policy->coveredTypeNames() as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
$queuedQuery
->where('status', OperationRunStatus::Queued->value)
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
})
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
$runningQuery
->where('status', OperationRunStatus::Running->value)
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
$startedAtQuery
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
$fallbackQuery
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
});
});
});
});
});
}
})
->count();
return [
'likely_stale' => $likelyStale,
'reconciled' => $reconciled,
];
}
private function applyActiveTab(Builder $query): Builder
{
return match ($this->activeTab) {
@ -84,6 +235,9 @@ private function applyActiveTab(Builder $query): Builder
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
]),
'blocked' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Blocked->value),
'succeeded' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value),
@ -96,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
default => $query,
};
}
private function scopedSummaryQuery(): ?Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! $workspaceId) {
return null;
}
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return OperationRun::query()
->where('workspace_id', (int) $workspaceId)
->when(
is_numeric($tenantFilter),
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
);
}
}

View File

@ -8,18 +8,37 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
class TenantlessOperationRunViewer extends Page
{
use AuthorizesRequests;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
@ -30,6 +49,11 @@ class TenantlessOperationRunViewer extends Page
public OperationRun $run;
/**
* @var array<string, mixed>|null
*/
public ?array $navigationContextPayload = null;
public bool $opsUxIsTabHidden = false;
/**
@ -37,28 +61,55 @@ class TenantlessOperationRunViewer extends Page
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$navigationContext = $this->navigationContext();
$activeTenant = $operateHubShell->activeEntitledTenant(request());
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
$actions = [
Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
Action::make('operate_hub_scope_run_detail')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index')),
->disabled(),
];
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
}
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
}
$actions[] = Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->color('gray')
->url(fn (): string => isset($this->run)
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: route('admin.operations.index'));
if (! isset($this->run)) {
return $actions;
}
$user = auth()->user();
$tenant = $this->run->tenant;
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
$tenant = null;
}
$related = OperationRunLinks::related($this->run, $tenant);
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
$relatedActions = [];
@ -76,6 +127,8 @@ protected function getHeaderActions(): array
->color('gray');
}
$actions[] = $this->resumeCaptureAction();
return $actions;
}
@ -87,22 +140,10 @@ public function mount(OperationRun $run): void
abort(403);
}
$workspaceId = (int) ($run->workspace_id ?? 0);
if ($workspaceId <= 0) {
abort(404);
}
$isMember = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isMember) {
abort(404);
}
$this->authorize('view', $run);
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
}
public function infolist(Schema $schema): Schema
@ -117,10 +158,311 @@ public function defaultInfolist(Schema $schema): Schema
->columns(2);
}
public function redactionIntegrityNote(): ?string
{
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function blockedExecutionBanner(): ?array
{
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
return null;
}
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $operatorExplanation instanceof OperatorExplanationPattern
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
]))
: ($reasonEnvelope?->toBodyLines(false) ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
]);
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', array_values(array_unique($lines))),
];
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function lifecycleBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
if ($attention === null) {
return null;
}
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
'tone' => 'amber',
'title' => 'Likely stale run',
'body' => $detail,
],
'reconciled_failed' => [
'tone' => 'rose',
'title' => 'Automatically reconciled',
'body' => $detail,
],
default => null,
};
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function canonicalContextBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$runTenant = $this->run->tenant;
if (! $runTenant instanceof Tenant) {
return [
'tone' => 'slate',
'title' => 'Workspace-level run',
'body' => $activeTenant instanceof Tenant
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
: 'This canonical workspace view is not tied to any tenant.',
];
}
$messages = ['Run tenant: '.$runTenant->name.'.'];
$tone = 'sky';
$title = null;
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
$title = 'Current tenant context differs from this run';
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
}
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
$title ??= 'Run tenant is not available in the current tenant selector';
$tone = 'amber';
$messages[] = $selectorAvailabilityMessage;
if ($referencedTenant->contextNote !== null) {
$messages[] = $referencedTenant->contextNote;
}
} elseif (! $activeTenant instanceof Tenant) {
$title ??= 'Canonical workspace view';
$messages[] = 'No tenant context is currently selected.';
}
if ($title === null) {
return null;
}
return [
'tone' => $tone,
'title' => $title,
'body' => implode(' ', $messages),
];
}
public function pollInterval(): ?string
{
if (! isset($this->run)) {
return null;
}
if ($this->opsUxIsTabHidden === true) {
return null;
}
if (filled($this->mountedActions ?? null)) {
return null;
}
return RunDetailPolling::interval($this->run);
}
public function content(Schema $schema): Schema
{
return $schema->schema([
EmbeddedSchema::make('infolist'),
]);
}
private function resumeCaptureAction(): Action
{
return Action::make('resumeCapture')
->label('Resume capture')
->icon('heroicon-o-forward')
->requiresConfirmation()
->modalHeading('Resume capture')
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
->visible(fn (): bool => $this->canResumeCapture())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! isset($this->run)) {
Notification::make()
->title('Run not loaded')
->danger()
->send();
return;
}
$service = app(BaselineEvidenceCaptureResumeService::class);
$result = $service->resume($this->run, $user);
if (! ($result['ok'] ?? false)) {
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
Notification::make()
->title('Cannot resume capture')
->body('Reason: '.str_replace('.', ' ', $reason))
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if (! $run instanceof OperationRun) {
Notification::make()
->title('Cannot resume capture')
->body('Reason: missing operation run')
->danger()
->send();
return;
}
$viewAction = Action::make('view_run')
->label('View run')
->url(OperationRunLinks::tenantlessView($run));
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
->actions([$viewAction])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $run->type)
->actions([$viewAction])
->send();
});
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
return CanonicalNavigationContext::fromRequest($request);
}
private function canResumeCapture(): bool
{
if (! isset($this->run)) {
return false;
}
if ((string) $this->run->status !== 'completed') {
return false;
}
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
return false;
}
$context = is_array($this->run->context) ? $this->run->context : [];
$tokenKey = (string) $this->run->type === 'baseline_capture'
? 'baseline_capture.resume_token'
: 'baseline_compare.resume_token';
$token = data_get($context, $tokenKey);
if (! is_string($token) || trim($token) === '') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = $this->run->workspace;
if (! $workspace instanceof \App\Models\Workspace) {
return false;
}
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
private function relatedLinksTenant(): ?Tenant
{
if (! isset($this->run)) {
return null;
}
$user = auth()->user();
$tenant = $this->run->tenant;
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
return null;
}
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::SelectorEligibility,
actor: $user,
workspaceId: (int) ($this->run->workspace_id ?? 0),
lane: TenantInteractionLane::StandardActiveOperating,
)->allowed ? $tenant : null;
}
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
{
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
return null;
}
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
}
}

View File

@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\TenantReviewCompletenessState;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class ReviewRegister extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Reviews';
protected static ?string $title = 'Review Register';
protected static ?string $slug = 'reviews';
protected string $view = 'filament.pages.reviews.review-register';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
}
public function mount(): void
{
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['status', 'published_state', 'completeness_state'],
request(),
);
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void {
$this->resetTable();
}),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->registerQuery())
->defaultSort('generated_at', 'desc')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
->columns([
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
->wrap(),
TextColumn::make('completeness_state')
->label('Completeness')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('publication_truth')
->label('Publication')
->badge()
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
->wrap(),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->searchable(),
SelectFilter::make('status')
->options([
'draft' => 'Draft',
'ready' => 'Ready',
'published' => 'Published',
'archived' => 'Archived',
'superseded' => 'Superseded',
'failed' => 'Failed',
]),
SelectFilter::make('completeness_state')
->label('Completeness')
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
SelectFilter::make('published_state')
->label('Published state')
->options([
'published' => 'Published',
'unpublished' => 'Not published',
])
->query(function (Builder $query, array $data): Builder {
return match ($data['value'] ?? null) {
'published' => $query->whereNotNull('published_at'),
'unpublished' => $query->whereNull('published_at'),
default => $query,
};
}),
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
])
->actions([
Action::make('view_review')
->label('View review')
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true))
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
])
->bulkActions([])
->emptyStateHeading('No review records match this view')
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
->emptyStateActions([
Action::make('clear_filters_empty')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->action(fn (): mixed => $this->resetTable()),
]);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
throw new NotFoundHttpException;
}
if ($this->authorizedTenants() === []) {
throw new NotFoundHttpException;
}
}
private function registerQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return TenantReview::query()->whereRaw('1 = 0');
}
return app(TenantReviewRegisterService::class)->query($user, $workspace);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function defaultTenantFilter(): ?string
{
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
? (string) $tenantId
: null;
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function hasActiveFilters(): bool
{
$filters = array_filter((array) $this->tableFilters);
return $filters !== [];
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_numeric($workspaceId)
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
@ -31,6 +32,7 @@ public function getWidgets(): array
return [
DashboardKpis::class,
NeedsAttention::class,
BaselineCompareNow::class,
RecentDriftFindings::class,
RecentOperations::class,
];

View File

@ -4,7 +4,7 @@
namespace App\Filament\Pages;
use App\Models\Tenant;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\TenantDiagnosticsService;
@ -17,6 +17,8 @@
class TenantDiagnostics extends Page
{
use ResolvesPanelTenantContext;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'diagnostics';
@ -29,7 +31,7 @@ class TenantDiagnostics extends Page
public function mount(): void
{
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$tenantId = (int) $tenant->getKey();
$this->missingOwner = ! TenantMembership::query()
@ -80,7 +82,7 @@ protected function getHeaderActions(): array
public function bootstrapOwner(): void
{
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$user = auth()->user();
if (! $user instanceof User) {
@ -94,7 +96,7 @@ public function bootstrapOwner(): void
public function mergeDuplicateMemberships(): void
{
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$user = auth()->user();
if (! $user instanceof User) {

View File

@ -5,13 +5,15 @@
namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
use Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantRequiredPermissions extends Page
{
@ -41,34 +43,31 @@ class TenantRequiredPermissions extends Page
*/
public array $viewModel = [];
#[Locked]
public ?int $scopedTenantId = null;
public static function canAccess(): bool
{
$tenant = static::resolveScopedTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
return static::hasScopedTenantAccess(static::resolveScopedTenant());
}
public function currentTenant(): ?Tenant
{
return static::resolveScopedTenant();
return $this->trustedScopedTenant();
}
public function mount(): void
{
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404);
}
$this->scopedTenantId = (int) $tenant->getKey();
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
$queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
@ -147,7 +146,7 @@ public function resetFilters(): void
private function refreshViewModel(): void
{
$tenant = static::resolveScopedTenant();
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
@ -174,25 +173,26 @@ private function refreshViewModel(): void
}
}
public function reRunVerificationUrl(): ?string
public function reRunVerificationUrl(): string
{
$tenant = static::resolveScopedTenant();
$tenant = $this->trustedScopedTenant();
if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]);
}
return route('admin.onboarding');
}
public function manageProviderConnectionUrl(): ?string
{
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
return null;
}
$connectionId = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('is_default')
->orderByDesc('id')
->value('id');
if (! is_int($connectionId)) {
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant
@ -209,6 +209,75 @@ protected static function resolveScopedTenant(): ?Tenant
->first();
}
return Tenant::current();
return null;
}
private static function hasScopedTenantAccess(?Tenant $tenant): bool
{
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
$isWorkspaceMember = WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isWorkspaceMember) {
return false;
}
return $user->canAccessTenant($tenant);
}
private function trustedScopedTenant(): ?Tenant
{
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$workspaceContext = app(WorkspaceContext::class);
try {
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
} catch (NotFoundHttpException) {
return null;
}
$routeTenant = static::resolveScopedTenant();
if ($routeTenant instanceof Tenant) {
try {
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
} catch (NotFoundHttpException) {
return null;
}
}
if ($this->scopedTenantId === null) {
return null;
}
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
if (! $tenant instanceof Tenant) {
return null;
}
try {
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
} catch (NotFoundHttpException) {
return null;
}
}
}

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use BackedEnum;
use Filament\Navigation\NavigationItem;
use Filament\Pages\Page;
use UnitEnum;
class WorkspaceOverview extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Overview';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-home';
protected static string|UnitEnum|null $navigationGroup = null;
protected string $view = 'filament.pages.workspace-overview';
/**
* @var array<string, mixed>
*/
public array $overview = [];
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Workspace overview is a singleton landing page with no page-header actions.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace overview is already the canonical landing surface for the active workspace.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace overview does not render record rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace overview does not expose bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Workspace overview redirects or renders overview content instead of a list-style empty state.');
}
public function mount(WorkspaceOverviewBuilder $builder): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
$this->redirect('/admin/choose-workspace');
return;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
$this->overview = $builder->build($workspace, $user);
}
public static function navigationItem(): NavigationItem
{
return NavigationItem::make('Overview')
->url(fn (): string => route('admin.home'))
->icon('heroicon-o-home')
->sort(-100)
->isActiveWhen(fn (): bool => request()->routeIs('admin.home'));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,14 @@
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
@ -18,12 +22,26 @@ class ManagedTenantsLanding extends Page
protected static bool $isDiscovered = false;
protected static string $layout = 'filament-panels::components.layout.simple';
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace;
/**
* The Filament simple layout renders the topbar by default, which includes
* lazy-loaded database notifications. On this workspace-scoped landing page,
* those background Livewire requests currently 404.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
public function mount(Workspace $workspace): void
{
$this->workspace = $workspace;
@ -40,11 +58,25 @@ public function getTenants(): Collection
return Tenant::query()->whereRaw('1 = 0')->get();
}
return $user->tenants()
$tenantIds = $user->tenantMemberships()
->pluck('tenant_id');
return Tenant::query()
->withTrashed()
->whereIn('id', $tenantIds)
->where('workspace_id', $this->workspace->getKey())
->where('status', 'active')
->orderBy('name')
->get();
->get()
->filter(function (Tenant $tenant) use ($user): bool {
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
actor: $user,
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
lane: TenantInteractionLane::AdministrativeManagement,
)->allowed;
})
->values();
}
public function goToChooseTenant(): void
@ -61,7 +93,7 @@ public function openTenant(int $tenantId): void
}
$tenant = Tenant::query()
->where('status', 'active')
->withTrashed()
->where('workspace_id', $this->workspace->getKey())
->whereKey($tenantId)
->first();
@ -74,6 +106,6 @@ public function openTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
}
}

View File

@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class AlertDeliveryResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDelivery::class;
protected static ?string $slug = 'alert-deliveries';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 1;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'id';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert deliveries';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertDelivery::class);
}
public static function canView(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDelivery) {
return false;
}
return $user->can('view', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Guided empty state links to View alert rules.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
}
public static function makeViewAlertRulesAction(): Action
{
return Action::make('view_alert_rules')
->label('View alert rules')
->icon('heroicon-o-funnel')
->color('primary')
->button()
->url(AlertRuleResource::getUrl(panel: 'admin'));
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$user = auth()->user();
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return parent::getEloquentQuery()
->with(['tenant', 'rule', 'destination'])
->when(
! $user instanceof User,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
! is_int($workspaceId),
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
is_int($workspaceId),
fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId),
)
->when(
$user instanceof User,
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
$q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'))
->orWhereNull('tenant_id');
}),
)
->when(
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
)
->latest('id');
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Delivery')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)),
TextEntry::make('event_type')
->label('Event')
->badge()
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
TextEntry::make('severity')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
->placeholder('—'),
TextEntry::make('tenant.name')
->label('Tenant'),
TextEntry::make('rule.name')
->label('Rule')
->placeholder('—'),
TextEntry::make('destination.name')
->label('Destination')
->placeholder('—'),
TextEntry::make('attempt_count')
->label('Attempts'),
TextEntry::make('fingerprint_hash')
->label('Fingerprint')
->copyable(),
TextEntry::make('send_after')
->dateTime()
->placeholder('—'),
TextEntry::make('sent_at')
->dateTime()
->placeholder('—'),
TextEntry::make('last_error_code')
->label('Last error code')
->placeholder('—'),
TextEntry::make('last_error_message')
->label('Last error message')
->placeholder('—')
->columnSpanFull(),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2)
->columnSpanFull(),
Section::make('Payload')
->schema([
ViewEntry::make('payload')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (AlertDelivery $record): array => is_array($record->payload) ? $record->payload : [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->columns([
TextColumn::make('created_at')
->label('Created')
->since()
->sortable(),
TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
TextColumn::make('event_type')
->label('Event')
->badge(),
TextColumn::make('severity')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
->placeholder('—'),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus))
->sortable(),
TextColumn::make('rule.name')
->label('Rule')
->placeholder('—'),
TextColumn::make('destination.name')
->label('Destination')
->placeholder('—'),
TextColumn::make('attempt_count')
->label('Attempts')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(function (): array {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
return [
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
];
}
$user = auth()->user();
if (! $user instanceof User) {
return [];
}
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->getFilamentName(),
])
->all();
})
->default(function (): ?string {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
return null;
}
return (string) $activeTenant->getKey();
})
->searchable(),
SelectFilter::make('status')
->options(FilterOptionCatalog::alertDeliveryStatuses()),
SelectFilter::make('event_type')
->label('Event type')
->options(function (): array {
$options = AlertRuleResource::eventTypeOptions();
$options[AlertDelivery::EVENT_TYPE_TEST] = 'Test';
return $options;
}),
SelectFilter::make('alert_destination_id')
->label('Destination')
->options(function (): array {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return AlertDestination::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}),
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
])
->actions([
ViewAction::make()->label('View'),
])
->bulkActions([])
->emptyStateHeading('No alert deliveries')
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
->emptyStateIcon('heroicon-o-bell-alert')
->emptyStateActions([
static::makeViewAlertRulesAction(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListAlertDeliveries::route('/'),
'view' => Pages\ViewAlertDelivery::route('/{record}'),
];
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperateHub\OperateHubShell;
use Filament\Resources\Pages\ListRecords;
class ListAlertDeliveries extends ListRecords
{
protected static string $resource = AlertDeliveryResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
parent::mount();
}
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAlertDelivery extends ViewRecord
{
protected static string $resource = AlertDeliveryResource::class;
}

View File

@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDestinationResource\Pages;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class AlertDestinationResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDestination::class;
protected static ?string $slug = 'alert-destinations';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 3;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert targets';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertDestination::class);
}
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('create', AlertDestination::class);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return false;
}
return $user->can('update', $record);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return false;
}
return $user->can('delete', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert destinations in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Select::make('type')
->required()
->options(self::typeOptions())
->native(false)
->live(),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
TextInput::make('teams_webhook_url')
->label('Teams webhook URL')
->placeholder('https://...')
->url()
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK),
TagsInput::make('email_recipients')
->label('Email recipients')
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL)
->placeholder('ops@example.com')
->nestedRecursiveRules(['email']),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: static::getUrl('view', ['record' => $record]))
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
TextColumn::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextColumn::make('updated_at')
->since(),
])
->actions([
EditAction::make()
->label('Edit')
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
ActionGroup::make([
Action::make('toggle_enabled')
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
->action(function (AlertDestination $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('update', $record)) {
throw new AuthorizationException;
}
$enabled = ! (bool) $record->is_enabled;
$record->forceFill([
'is_enabled' => $enabled,
])->save();
$actionId = $enabled
? AuditActionId::AlertDestinationEnabled
: AuditActionId::AlertDestinationDisabled;
self::audit($record, $actionId, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => $enabled,
]);
Notification::make()
->title($enabled ? 'Destination enabled' : 'Destination disabled')
->success()
->send();
}),
Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (AlertDestination $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('delete', $record)) {
throw new AuthorizationException;
}
self::audit($record, AuditActionId::AlertDestinationDeleted, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
]);
$record->delete();
Notification::make()
->title('Destination deleted')
->success()
->send();
}),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
])
->emptyStateActions([
\Filament\Actions\CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! static::canCreate()),
])
->emptyStateHeading('No alert destinations')
->emptyStateDescription('Create a destination so alert rules have somewhere to deliver notifications.')
->emptyStateIcon('heroicon-o-paper-airplane');
}
public static function getPages(): array
{
return [
'index' => Pages\ListAlertDestinations::route('/'),
'create' => Pages\CreateAlertDestination::route('/create'),
'view' => Pages\ViewAlertDestination::route('/{record}'),
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
];
}
/**
* @param array<string, mixed> $data
*/
public static function normalizePayload(array $data, ?AlertDestination $record = null): array
{
$type = trim((string) ($data['type'] ?? $record?->type ?? ''));
$existingConfig = is_array($record?->config ?? null) ? $record->config : [];
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
$webhookUrl = trim((string) ($data['teams_webhook_url'] ?? ''));
if ($webhookUrl === '' && $record instanceof AlertDestination) {
$webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', ''));
}
$data['config'] = [
'webhook_url' => $webhookUrl,
];
}
if ($type === AlertDestination::TYPE_EMAIL) {
$recipients = Arr::wrap($data['email_recipients'] ?? []);
$recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)));
if ($recipients === [] && $record instanceof AlertDestination) {
$existingRecipients = Arr::get($existingConfig, 'recipients', []);
$recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : [];
}
$data['config'] = [
'recipients' => array_values(array_unique($recipients)),
];
}
unset($data['teams_webhook_url'], $data['email_recipients']);
return $data;
}
/**
* @return array<string, string>
*/
public static function typeOptions(): array
{
return [
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook',
AlertDestination::TYPE_EMAIL => 'Email',
];
}
public static function typeLabel(string $type): string
{
return self::typeOptions()[$type] ?? ucfirst($type);
}
/**
* @param array<string, mixed> $data
*/
public static function assertValidConfigPayload(array $data): void
{
$type = (string) ($data['type'] ?? '');
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
$webhook = trim((string) Arr::get($config, 'webhook_url', ''));
if ($webhook === '') {
throw ValidationException::withMessages([
'teams_webhook_url' => ['The Teams webhook URL is required.'],
]);
}
}
if ($type === AlertDestination::TYPE_EMAIL) {
$recipients = Arr::get($config, 'recipients', []);
$recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : [];
if ($recipients === []) {
throw ValidationException::withMessages([
'email_recipients' => ['At least one recipient is required for email destinations.'],
]);
}
}
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => $metadata,
],
actor: auth()->user() instanceof User ? auth()->user() : null,
resourceType: 'alert_destination',
resourceId: (string) $record->getKey(),
);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateAlertDestination extends CreateRecord
{
protected static string $resource = AlertDestinationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
$data['workspace_id'] = (int) $workspaceId;
$data = AlertDestinationResource::normalizePayload($data);
AlertDestinationResource::assertValidConfigPayload($data);
return $data;
}
protected function afterCreate(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationCreated, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => (bool) $record->is_enabled,
]);
Notification::make()
->title('Destination created')
->success()
->send();
}
}

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Services\Alerts\AlertDestinationTestMessageService;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Audit\AuditActionId;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditAlertDestination extends EditRecord
{
protected static string $resource = AlertDestinationResource::class;
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
public function mount(int|string $record): void
{
parent::mount($record);
$this->resolveLastTestStatus();
}
protected function getHeaderActions(): array
{
$user = auth()->user();
$record = $this->record;
$canManage = $user instanceof User
&& $record instanceof AlertDestination
&& $user->can('update', $record);
return [
Action::make('send_test_message')
->label('Send test message')
->icon('heroicon-o-paper-airplane')
->requiresConfirmation()
->modalHeading('Send test message')
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
->modalSubmitActionLabel('Send')
->visible(fn (): bool => $record instanceof AlertDestination)
->disabled(fn (): bool => ! $canManage)
->action(function () use ($record): void {
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return;
}
$service = app(AlertDestinationTestMessageService::class);
$result = $service->sendTest($record, $user);
if ($result['success']) {
Notification::make()
->title($result['message'])
->success()
->send();
} else {
Notification::make()
->title($result['message'])
->warning()
->send();
}
$this->resolveLastTestStatus();
}),
Action::make('view_last_delivery')
->label('View last delivery')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->buildDeepLinkUrl())
->openUrlInNewTab()
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
];
}
public function getSubheading(): ?string
{
if ($this->lastTestStatus === null) {
return null;
}
$label = ucfirst($this->lastTestStatus->status->value);
$timestamp = $this->lastTestStatus->timestamp?->diffForHumans();
return $timestamp !== null
? "Last test: {$label} ({$timestamp})"
: "Last test: {$label}";
}
protected function mutateFormDataBeforeSave(array $data): array
{
$record = $this->record;
$data = AlertDestinationResource::normalizePayload(
data: $data,
record: $record instanceof AlertDestination ? $record : null,
);
AlertDestinationResource::assertValidConfigPayload($data);
return $data;
}
protected function afterSave(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationUpdated, [
'alert_destination_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'type' => (string) $record->type,
'is_enabled' => (bool) $record->is_enabled,
]);
Notification::make()
->title('Destination updated')
->success()
->send();
}
private function resolveLastTestStatus(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
}
private function buildDeepLinkUrl(): ?string
{
$record = $this->record;
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
return null;
}
$baseUrl = AlertDeliveryResource::getUrl('index');
$params = http_build_query([
'filters' => [
'event_type' => ['value' => 'alerts.test'],
'alert_destination_id' => ['value' => (string) $record->getKey()],
],
]);
return "{$baseUrl}?{$params}";
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAlertDestinations extends ListRecords
{
protected static string $resource = AlertDestinationResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
];
}
}

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Alerts\AlertDestinationLastTestResolver;
use App\Services\Alerts\AlertDestinationTestMessageService;
use App\Support\Alerts\AlertDestinationLastTestStatus;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions\Action;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ViewAlertDestination extends ViewRecord
{
protected static string $resource = AlertDestinationResource::class;
private ?AlertDestinationLastTestStatus $lastTestStatus = null;
public function mount(int|string $record): void
{
parent::mount($record);
$this->resolveLastTestStatus();
}
protected function getHeaderActions(): array
{
$user = auth()->user();
$record = $this->record;
$canManage = $user instanceof User
&& $record instanceof AlertDestination
&& $user->can('update', $record);
return [
Action::make('send_test_message')
->label('Send test message')
->icon('heroicon-o-paper-airplane')
->requiresConfirmation()
->modalHeading('Send test message')
->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.')
->modalSubmitActionLabel('Send')
->visible(fn (): bool => $record instanceof AlertDestination)
->disabled(fn (): bool => ! $canManage)
->action(function () use ($record): void {
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertDestination) {
return;
}
$service = app(AlertDestinationTestMessageService::class);
$result = $service->sendTest($record, $user);
if ($result['success']) {
Notification::make()
->title($result['message'])
->success()
->send();
} else {
Notification::make()
->title($result['message'])
->warning()
->send();
}
$this->resolveLastTestStatus();
}),
Action::make('view_last_delivery')
->label('View last delivery')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->buildDeepLinkUrl())
->openUrlInNewTab()
->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null),
];
}
public function infolist(Schema $schema): Schema
{
$lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never();
return $schema
->schema([
Section::make('Last test')
->schema([
TextEntry::make('last_test_status')
->label('Status')
->badge()
->state($lastTest->status->value)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus))
->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus))
->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)),
TextEntry::make('last_test_timestamp')
->label('Timestamp')
->state($lastTest->timestamp?->toDateTimeString())
->placeholder('—'),
])
->columns(2),
Section::make('Details')
->schema([
TextEntry::make('name'),
TextEntry::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)),
TextEntry::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2),
]);
}
private function resolveLastTestStatus(): void
{
$record = $this->record;
if (! $record instanceof AlertDestination) {
return;
}
$this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record);
}
private function buildDeepLinkUrl(): ?string
{
$record = $this->record;
if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) {
return null;
}
return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([
'filters' => [
'event_type' => ['value' => 'alerts.test'],
'alert_destination_id' => ['value' => (string) $record->getKey()],
],
]);
}
}

View File

@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertRuleResource\Pages;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use UnitEnum;
class AlertRuleResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertRule::class;
protected static ?string $slug = 'alert-rules';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 2;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert rules';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertRule::class);
}
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('create', AlertRule::class);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertRule) {
return false;
}
return $user->can('update', $record);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertRule) {
return false;
}
return $user->can('delete', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with('destinations')
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Rule')
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
Select::make('event_type')
->required()
->options(self::eventTypeOptions())
->native(false),
Select::make('minimum_severity')
->required()
->options(self::severityOptions())
->native(false),
]),
Section::make('Applies to')
->schema([
Select::make('tenant_scope_mode')
->label('Applies to tenants')
->required()
->options([
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants',
])
->default(AlertRule::TENANT_SCOPE_ALL)
->native(false)
->live()
->helperText('This rule is workspace-wide. Use this to limit where it applies.'),
Select::make('tenant_allowlist')
->label('Selected tenants')
->multiple()
->options(self::tenantOptions())
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
->native(false)
->helperText('Only these tenants will trigger this rule.'),
]),
Section::make('Delivery')
->schema([
TextInput::make('cooldown_seconds')
->label('Cooldown (seconds)')
->numeric()
->minValue(0)
->nullable(),
Toggle::make('quiet_hours_enabled')
->label('Enable quiet hours')
->default(false)
->live(),
TextInput::make('quiet_hours_start')
->label('Quiet hours start')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
TextInput::make('quiet_hours_end')
->label('Quiet hours end')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('quiet_hours_timezone')
->label('Quiet hours timezone')
->options(self::timezoneOptions())
->searchable()
->native(false)
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('destination_ids')
->label('Destinations')
->multiple()
->required()
->options(self::destinationOptions())
->native(false),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: null)
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('event_type')
->label('Event')
->badge()
->formatStateUsing(fn (?string $state): string => self::eventTypeLabel((string) $state)),
TextColumn::make('minimum_severity')
->label('Min severity')
->badge()
->formatStateUsing(fn (?string $state): string => self::severityOptions()[(string) $state] ?? ucfirst((string) $state)),
TextColumn::make('destinations_count')
->label('Destinations')
->counts('destinations'),
TextColumn::make('is_enabled')
->label('Enabled')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
])
->actions([
EditAction::make()
->label('Edit')
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
ActionGroup::make([
Action::make('toggle_enabled')
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
->icon(fn (AlertRule $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
->requiresConfirmation()
->action(function (AlertRule $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('update', $record)) {
throw new AuthorizationException;
}
$enabled = ! (bool) $record->is_enabled;
$record->forceFill([
'is_enabled' => $enabled,
])->save();
$actionId = $enabled
? AuditActionId::AlertRuleEnabled
: AuditActionId::AlertRuleDisabled;
self::audit($record, $actionId, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
'is_enabled' => $enabled,
]);
Notification::make()
->title($enabled ? 'Rule enabled' : 'Rule disabled')
->success()
->send();
}),
Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (AlertRule $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $user->can('delete', $record)) {
throw new AuthorizationException;
}
self::audit($record, AuditActionId::AlertRuleDeleted, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
]);
$record->delete();
Notification::make()
->title('Rule deleted')
->success()
->send();
}),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
])
->emptyStateHeading('No alert rules')
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
->emptyStateIcon('heroicon-o-bell');
}
public static function getPages(): array
{
return [
'index' => Pages\ListAlertRules::route('/'),
'create' => Pages\CreateAlertRule::route('/create'),
'edit' => Pages\EditAlertRule::route('/{record}/edit'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function normalizePayload(array $data): array
{
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
$tenantAllowlist = [];
}
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
$data['tenant_allowlist'] = $tenantAllowlist;
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
$data['quiet_hours_enabled'] = $quietHoursEnabled;
if (! $quietHoursEnabled) {
$data['quiet_hours_start'] = null;
$data['quiet_hours_end'] = null;
$data['quiet_hours_timezone'] = null;
}
return $data;
}
/**
* @param array<int, int> $destinationIds
*/
public static function syncDestinations(AlertRule $record, array $destinationIds): void
{
$allowedDestinationIds = AlertDestination::query()
->where('workspace_id', (int) $record->workspace_id)
->whereIn('id', $destinationIds)
->pluck('id')
->map(static fn (mixed $value): int => (int) $value)
->all();
$record->destinations()->syncWithPivotValues(
array_values(array_unique($allowedDestinationIds)),
['workspace_id' => (int) $record->workspace_id],
);
}
/**
* @return array<string, string>
*/
public static function eventTypeOptions(): array
{
return [
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
AlertRule::EVENT_BASELINE_HIGH_DRIFT => 'Baseline drift',
AlertRule::EVENT_BASELINE_COMPARE_FAILED => 'Baseline compare failed',
AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
];
}
/**
* @return array<string, string>
*/
public static function severityOptions(): array
{
return [
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
'critical' => 'Critical',
];
}
public static function eventTypeLabel(string $eventType): string
{
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
}
/**
* @return array<int, string>
*/
private static function destinationOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return AlertDestination::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<int, string>
*/
private static function tenantOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return Tenant::query()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<string, string>
*/
private static function timezoneOptions(): array
{
$identifiers = \DateTimeZone::listIdentifiers();
sort($identifiers);
return array_combine($identifiers, $identifiers);
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
$actor = auth()->user();
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => $metadata,
],
actor: $actor instanceof User ? $actor : null,
resourceType: 'alert_rule',
resourceId: (string) $record->getKey(),
);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
class CreateAlertRule extends CreateRecord
{
protected static string $resource = AlertRuleResource::class;
/**
* @var array<int, int>
*/
private array $destinationIds = [];
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
$data['workspace_id'] = (int) $workspaceId;
$this->destinationIds = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): int => (int) $value,
Arr::wrap($data['destination_ids'] ?? []),
))));
unset($data['destination_ids']);
return AlertRuleResource::normalizePayload($data);
}
protected function afterCreate(): void
{
$record = $this->record;
if (! $record instanceof AlertRule) {
return;
}
AlertRuleResource::syncDestinations($record, $this->destinationIds);
AlertRuleResource::audit($record, AuditActionId::AlertRuleCreated, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
'minimum_severity' => (string) $record->minimum_severity,
'is_enabled' => (bool) $record->is_enabled,
'destination_ids' => $this->destinationIds,
]);
Notification::make()
->title('Rule created')
->success()
->send();
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
class EditAlertRule extends EditRecord
{
protected static string $resource = AlertRuleResource::class;
/**
* @var array<int, int>
*/
private array $destinationIds = [];
protected function mutateFormDataBeforeFill(array $data): array
{
$record = $this->record;
if ($record instanceof AlertRule) {
$data['destination_ids'] = $record->destinations()
->pluck('alert_destinations.id')
->map(static fn (mixed $value): int => (int) $value)
->all();
}
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
$this->destinationIds = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): int => (int) $value,
Arr::wrap($data['destination_ids'] ?? []),
))));
unset($data['destination_ids']);
return AlertRuleResource::normalizePayload($data);
}
protected function afterSave(): void
{
$record = $this->record;
if (! $record instanceof AlertRule) {
return;
}
AlertRuleResource::syncDestinations($record, $this->destinationIds);
AlertRuleResource::audit($record, AuditActionId::AlertRuleUpdated, [
'alert_rule_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'event_type' => (string) $record->event_type,
'minimum_severity' => (string) $record->minimum_severity,
'is_enabled' => (bool) $record->is_enabled,
'destination_ids' => $this->destinationIds,
]);
Notification::make()
->title('Rule updated')
->success()
->send();
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAlertRules extends ListRecords
{
protected static string $resource = AlertRuleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create rule')
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
CreateAction::make()
->label('Create rule')
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,17 @@
use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
class EditBackupSchedule extends EditRecord
{
protected static string $resource = BackupScheduleResource::class;
protected function resolveRecord(int|string $key): Model
{
return BackupScheduleResource::resolveScopedRecordOrFail($key);
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data = BackupScheduleResource::ensurePolicyTypes($data);

View File

@ -3,17 +3,65 @@
namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource;
use Filament\Actions;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ListBackupSchedules extends ListRecords
{
protected static string $resource = BackupScheduleResource::class;
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'restore', 'forceDelete'], true)) {
try {
BackupScheduleResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {
abort(404);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function mount(): void
{
$this->syncCanonicalAdminTenantFilterState();
parent::mount();
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
BackupScheduleResource::makeCreateAction()
->visible(fn (): bool => $this->tableHasRecords()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
BackupScheduleResource::makeCreateAction(),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: [],
request: request(),
tenantFilterName: null,
);
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Closure;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleOperationRunsRelationManager extends RelationManager
{
protected static string $relationship = 'operationRuns';
protected static ?string $title = 'Executions';
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedOperationRun($context['recordKey']);
}
return parent::mountAction($name, $arguments, $context);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
}
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Enqueued')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('type')
->label('Type')
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
Tables\Columns\TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (OperationRun $record): string {
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
$total = (int) ($counts['total'] ?? 0);
$succeeded = (int) ($counts['succeeded'] ?? 0);
$failed = (int) ($counts['failed'] ?? 0);
if ($total === 0 && $succeeded === 0 && $failed === 0) {
return '—';
}
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
}),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(function (OperationRun $record): string {
$record = $this->resolveOwnerScopedOperationRun($record);
$tenant = Tenant::currentOrFail();
return OperationRunLinks::view($record, $tenant);
})
->openUrlInNewTab(true),
])
->bulkActions([])
->emptyStateHeading('No schedule runs yet')
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
}
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
{
$recordId = $record instanceof OperationRun
? (int) $record->getKey()
: (is_numeric($record) ? (int) $record : 0);
if ($recordId <= 0) {
abort(404);
}
$resolvedRecord = $this->getOwnerRecord()
->operationRuns()
->where('tenant_id', Tenant::currentOrFail()->getKey())
->whereKey($recordId)
->first();
if (! $resolvedRecord instanceof OperationRun) {
abort(404);
}
return $resolvedRecord;
}
public static function formatOperationType(?string $state): string
{
return OperationCatalog::label($state);
}
}

View File

@ -1,107 +0,0 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleRunsRelationManager extends RelationManager
{
protected static string $relationship = 'runs';
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
->defaultSort('scheduled_for', 'desc')
->columns([
Tables\Columns\TextColumn::make('scheduled_for')
->label('Scheduled for')
->dateTime(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
Tables\Columns\TextColumn::make('duration')
->label('Duration')
->getStateUsing(function (BackupScheduleRun $record): string {
if (! $record->started_at || ! $record->finished_at) {
return '—';
}
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
if ($seconds < 60) {
return $seconds.'s';
}
$minutes = intdiv($seconds, 60);
$rem = $seconds % 60;
return sprintf('%dm %ds', $minutes, $rem);
}),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (BackupScheduleRun $record): string {
$summary = is_array($record->summary) ? $record->summary : [];
$total = (int) ($summary['policies_total'] ?? 0);
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
$errors = (int) ($summary['errors_count'] ?? 0);
if ($total === 0 && $backedUp === 0 && $errors === 0) {
return '—';
}
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
}),
Tables\Columns\TextColumn::make('error_code')
->label('Error')
->badge()
->default('—'),
Tables\Columns\TextColumn::make('error_message')
->label('Message')
->default('—')
->limit(80)
->wrap(),
Tables\Columns\TextColumn::make('backup_set_id')
->label('Backup set')
->default('—')
->url(function (BackupScheduleRun $record): ?string {
if (! $record->backup_set_id) {
return null;
}
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
})
->openUrlInNewTab(true),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->modalHeading('View backup schedule run')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(function (BackupScheduleRun $record): View {
return view('filament.modals.backup-schedule-run-view', [
'run' => $record,
]);
}),
])
->bulkActions([]);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\BulkBackupSetDeleteJob;
@ -18,9 +20,21 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -36,20 +50,63 @@
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use UnitEnum;
class BackupSetResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSet::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
}
public static function canViewAny(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canCreate(): bool
{
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -63,6 +120,16 @@ public static function canCreate(): bool
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery();
}
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
}
public static function form(Schema $schema): Schema
{
return $schema
@ -74,21 +141,40 @@ public static function form(Schema $schema): Schema
]);
}
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('Create backup set');
UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return $action;
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items'),
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TrashedFilter::make()
@ -97,10 +183,9 @@ public static function table(Table $table): Table
->trueLabel('All')
->falseLabel('Archived'),
])
->recordUrl(fn (BackupSet $record): string => static::getUrl('view', ['record' => $record]))
->actions([
Actions\ViewAction::make()
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
static::primaryRelatedAction(),
ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('restore')
@ -110,7 +195,7 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
$record->restore();
$record->items()->withTrashed()->restore();
@ -143,7 +228,7 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
$record->delete();
@ -175,7 +260,7 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
@ -245,7 +330,7 @@ public static function table(Table $table): Table
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
@ -315,7 +400,7 @@ public static function table(Table $table): Table
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
@ -400,7 +485,7 @@ public static function table(Table $table): Table
return [];
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
@ -452,7 +537,13 @@ public static function table(Table $table): Table
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
])->label('More'),
])
->emptyStateHeading('No backup sets')
->emptyStateDescription('Create a backup set to start protecting your configurations.')
->emptyStateIcon('heroicon-o-archive-box')
->emptyStateActions([
static::makeCreateAction(),
]);
}
@ -460,21 +551,11 @@ public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Infolists\Components\TextEntry::make('item_count')->label('Items'),
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
Infolists\Components\TextEntry::make('metadata')
->label('Metadata')
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
->copyable()
->copyMessage('Metadata copied'),
Infolists\Components\ViewEntry::make('enterprise_detail')
->label('')
->view('filament.infolists.entries.enterprise-detail.layout')
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray())
->columnSpanFull(),
]);
}
@ -512,13 +593,49 @@ private static function typeMeta(?string $type): array
->firstWhere('type', $type) ?? [];
}
/**
* @return list<array{
* key: string,
* label: string,
* value: string,
* secondaryValue: ?string,
* targetUrl: ?string,
* targetKind: string,
* availability: string,
* unavailableReason: ?string,
* contextBadge: ?string,
* priority: int,
* actionLabel: string
* }>
*/
public static function relatedContextEntries(BackupSet $record): array
{
return app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
}
private static function primaryRelatedAction(): Actions\Action
{
return Actions\Action::make('primary_drill_down')
->label(fn (BackupSet $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
->url(fn (BackupSet $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
->hidden(fn (BackupSet $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
->color('gray');
}
private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $record);
}
/**
* Create a backup set via the domain service instead of direct model mass-assignment.
*/
public static function createBackupSet(array $data): BackupSet
{
/** @var Tenant $tenant */
$tenant = Tenant::current();
$tenant = static::resolveTenantContextForCurrentPanel();
/** @var BackupService $service */
$service = app(BackupService::class);
@ -533,4 +650,94 @@ public static function createBackupSet(array $data): BackupSet
includeScopeTags: $data['include_scope_tags'] ?? false,
);
}
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
{
$factory = new EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
$metadata = is_array($record->metadata) ? $record->metadata : [];
$metadataKeyCount = count($metadata);
$relatedContext = static::relatedContextEntries($record);
$isArchived = $record->trashed();
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
->header(new SummaryHeaderData(
title: (string) $record->name,
subtitle: 'Backup set #'.$record->getKey(),
statusBadges: [
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
],
keyFacts: [
$factory->keyFact('Items', $record->item_count),
$factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
],
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
))
->addSection(
$factory->factsSection(
id: 'lifecycle_overview',
kind: 'core_details',
title: 'Lifecycle overview',
items: [
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Items', $record->item_count),
$factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Archived', $isArchived),
],
),
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => $relatedContext],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Recovery readiness',
items: [
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Archived', $isArchived),
$factory->keyFact('Metadata keys', $metadataKeyCount),
],
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Timing',
items: [
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Technical detail',
entries: [
$factory->keyFact('Metadata keys', $metadataKeyCount),
$factory->keyFact('Archived', $isArchived),
],
description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.',
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
viewData: ['payload' => $metadata],
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
),
)
->build();
}
private static function formatDetailTimestamp(mixed $value): string
{
if (! $value instanceof Carbon) {
return '—';
}
return $value->toDayDateTimeString();
}
}

View File

@ -3,19 +3,41 @@
namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiEnforcement;
use Filament\Actions;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords;
class ListBackupSets extends ListRecords
{
protected static string $resource = BackupSetResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
protected function getHeaderActions(): array
{
return [
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()),
BackupSetResource::makeCreateAction()
->visible(fn (): bool => $this->tableHasRecords()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
BackupSetResource::makeCreateAction(),
];
}
}

View File

@ -1,11 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewBackupSet extends ViewRecord
{
use ResolvesPanelTenantContext;
protected static string $resource = BackupSetResource::class;
protected function resolveRecord(int|string $key): Model
{
return BackupSetResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
$actions = [
Action::make('primary_related')
->label(fn (): string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->actionLabel ?? 'Open related record')
->url(fn (): ?string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->targetUrl)
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->isAvailable() ?? false))
->color('gray'),
];
$mutationActions = [
$this->restoreAction(),
$this->archiveAction(),
$this->forceDeleteAction(),
];
$actions[] = ActionGroup::make($mutationActions)
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray');
return $actions;
}
private function restoreAction(): Action
{
$action = Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
->action(function (AuditLogger $auditLogger): void {
/** @var BackupSet $record */
$record = $this->getRecord();
$record->restore();
$record->items()->withTrashed()->restore();
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.restored',
resourceType: 'backup_set',
resourceId: (string) $record->getKey(),
status: 'success',
context: ['metadata' => ['name' => $record->name]],
);
}
Notification::make()
->title('Backup set restored')
->success()
->send();
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
});
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return $action;
}
private function archiveAction(): Action
{
$action = Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && ! $this->getRecord()->trashed())
->action(function (AuditLogger $auditLogger): void {
/** @var BackupSet $record */
$record = $this->getRecord();
$record->delete();
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.deleted',
resourceType: 'backup_set',
resourceId: (string) $record->getKey(),
status: 'success',
context: ['metadata' => ['name' => $record->name]],
);
}
Notification::make()
->title('Backup set archived')
->success()
->send();
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
});
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return $action;
}
private function forceDeleteAction(): Action
{
$action = Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
->action(function (AuditLogger $auditLogger): void {
/** @var BackupSet $record */
$record = $this->getRecord();
if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make()
->title('Cannot force delete backup set')
->body('Backup sets referenced by restore runs cannot be removed.')
->danger()
->send();
return;
}
if ($record->tenant instanceof Tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'backup.force_deleted',
resourceType: 'backup_set',
resourceId: (string) $record->getKey(),
status: 'success',
context: ['metadata' => ['name' => $record->name]],
);
}
$record->items()->withTrashed()->forceDelete();
$record->forceDelete();
Notification::make()
->title('Backup set permanently deleted')
->success()
->send();
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
});
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply();
return $action;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Jobs\RemovePoliciesFromBackupSetJob;
use App\Models\BackupItem;
use App\Models\Tenant;
@ -13,14 +14,17 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
@ -39,6 +43,27 @@ public function closeAddPoliciesModal(): void
$this->unmountAction();
}
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true) {
$backupSet = $this->getOwnerRecord();
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
}
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function table(Table $table): Table
{
$refreshTable = Actions\Action::make('refreshTable')
@ -73,7 +98,7 @@ public function table(Table $table): Table
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (BackupItem $record): void {
->action(function (mixed $record): void {
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
@ -90,7 +115,7 @@ public function table(Table $table): Table
abort(404);
}
$backupItemIds = [(int) $record->getKey()];
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
@ -105,10 +130,9 @@ public function table(Table $table): Table
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
@ -170,14 +194,7 @@ public function table(Table $table): Table
abort(404);
}
$backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
if ($backupItemIds === []) {
return;
@ -196,10 +213,9 @@ public function table(Table $table): Table
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Removal already queued')
->body('A matching remove operation is already queued or running.')
->info()
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
@ -235,7 +251,12 @@ public function table(Table $table): Table
->apply();
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->modifyQueryUsing(fn (Builder $query) => $query->with(['policy', 'policyVersion', 'policyVersion.policy']))
->defaultSort('policy.display_name')
->paginated(TablePaginationProfiles::relationManager())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Item')
@ -270,7 +291,8 @@ public function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID')
->copyable(),
->copyable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
@ -312,11 +334,24 @@ public function table(Table $table): Table
}
return '—';
}),
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
Tables\Columns\TextColumn::make('created_at')->since(),
})
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('policy_type')
->label('Type')
->options(FilterOptionCatalog::policyTypes())
->searchable(),
SelectFilter::make('restore_mode')
->label('Restore')
->options(static::restoreModeOptions())
->query(fn (Builder $query, array $data): Builder => static::applyRestoreModeFilter($query, $data['value'] ?? null)),
SelectFilter::make('platform')
->options(FilterOptionCatalog::platforms())
->searchable(),
])
->filters([])
->headerActions([
$refreshTable,
$addPolicies,
@ -324,25 +359,37 @@ public function table(Table $table): Table
->actions([
Actions\ActionGroup::make([
Actions\ViewAction::make()
->label('View policy')
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
->url(function (BackupItem $record): ?string {
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
if ($record->policy_version_id) {
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
}
if (! $record->policy_id) {
return null;
}
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
})
->hidden(fn (BackupItem $record) => ! $record->policy_id)
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
->openUrlInNewTab(true),
$removeItem,
])->icon('heroicon-o-ellipsis-vertical'),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
])
->bulkActions([
Actions\BulkActionGroup::make([
$bulkRemove,
]),
])->label('More'),
])
->emptyStateHeading('No policies in this backup set')
->emptyStateDescription('Add policies to capture versions and assignments inside this backup set.')
->emptyStateActions([
$addPolicies->name('addPoliciesEmpty'),
]);
}
@ -363,4 +410,106 @@ private static function typeMeta(?string $type): array
return collect($types)
->firstWhere('type', $type) ?? [];
}
/**
* @return array<string, string>
*/
private static function restoreModeOptions(): array
{
return collect(InventoryPolicyTypeMeta::all())
->pluck('restore')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->sort()
->mapWithKeys(fn (string $value): array => [
$value => BadgeRenderer::spec(BadgeDomain::PolicyRestoreMode, $value)->label,
])
->all();
}
private static function applyRestoreModeFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || trim($value) === '') {
return $query;
}
$types = collect(InventoryPolicyTypeMeta::all())
->filter(fn (array $meta): bool => ($meta['restore'] ?? null) === $value)
->pluck('type')
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(fn (string $type): string => trim($type))
->values()
->all();
if ($types === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn('policy_type', $types);
}
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
{
$recordId = $this->normalizeBackupItemKey($record);
if ($recordId <= 0) {
abort(404);
}
$resolvedId = $backupSet->items()
->where('tenant_id', (int) $backupSet->tenant_id)
->whereKey($recordId)
->value('id');
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
abort(404);
}
return (int) $resolvedId;
}
/**
* @return array<int, int>
*/
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
{
$requestedIds = collect($recordKeys)
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($requestedIds === []) {
return [];
}
$resolvedIds = $backupSet->items()
->where('tenant_id', (int) $backupSet->tenant_id)
->whereIn('id', $requestedIds)
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if (count($resolvedIds) !== count($requestedIds)) {
abort(404);
}
return $resolvedIds;
}
private function normalizeBackupItemKey(mixed $record): int
{
if ($record instanceof BackupItem) {
return (int) $record->getKey();
}
return is_numeric($record) ? (int) $record : 0;
}
}

View File

@ -0,0 +1,754 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
use UnitEnum;
class BaselineProfileResource extends Resource
{
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $model = BaselineProfile::class;
protected static ?string $slug = 'baseline-profiles';
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baselines';
protected static ?int $navigationSort = 1;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = self::resolveWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
}
public static function canCreate(): bool
{
return self::hasManageCapability();
}
public static function canEdit(Model $record): bool
{
return self::hasManageCapability();
}
public static function canDelete(Model $record): bool
{
return self::hasManageCapability();
}
public static function canView(Model $record): bool
{
return self::canViewAny();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with(['activeSnapshot', 'createdByUser'])
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Profile')
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->rule(fn (?BaselineProfile $record): Unique => Rule::unique('baseline_profiles', 'name')
->where('workspace_id', $record?->workspace_id ?? app(WorkspaceContext::class)->currentWorkspaceId(request()))
->ignore($record))
->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description')
->rows(3)
->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'),
]),
Section::make('Controls')
->schema([
Select::make('status')
->required()
->options(fn (?BaselineProfile $record): array => self::statusOptionsForRecord($record))
->default(BaselineProfileStatus::Draft->value)
->native(false)
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
->helperText(fn (?BaselineProfile $record): string => match ($record?->status) {
BaselineProfileStatus::Archived => 'Archived baselines cannot be reactivated.',
BaselineProfileStatus::Active => 'Changing status to Archived is permanent.',
default => 'Only active baselines are enforced during compliance checks.',
}),
Select::make('capture_mode')
->label('Capture mode')
->required()
->options(BaselineCaptureMode::selectOptions())
->default(BaselineCaptureMode::Opportunistic->value)
->native(false)
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
->disableOptionWhen(function (string $value): bool {
if ($value !== BaselineCaptureMode::FullContent->value) {
return false;
}
return ! app(BaselineFullContentRolloutGate::class)->enabled();
})
->helperText(fn (): string => app(BaselineFullContentRolloutGate::class)->enabled()
? 'Full content capture enables deep drift detection by capturing policy evidence on demand.'
: 'Full content capture is currently disabled by rollout configuration.'),
TextInput::make('version_label')
->label('Version label')
->maxLength(50)
->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'),
Select::make('scope_jsonb.policy_types')
->label('Policy types')
->multiple()
->options(self::policyTypeOptions())
->helperText('Leave empty to include all supported policy types (excluding foundations).')
->native(false),
Select::make('scope_jsonb.foundation_types')
->label('Foundations')
->multiple()
->options(self::foundationTypeOptions())
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
->native(false),
Placeholder::make('metadata')
->label('Last modified')
->content(fn (?BaselineProfile $record): string => $record?->updated_at
? $record->updated_at->diffForHumans()
: '—')
->visible(fn (?BaselineProfile $record): bool => $record !== null),
])
->columns(2),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Profile')
->schema([
TextEntry::make('name'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
TextEntry::make('capture_mode')
->label('Capture mode')
->badge()
->formatStateUsing(function (mixed $state): string {
if ($state instanceof BaselineCaptureMode) {
return $state->label();
}
$parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null;
return $parsed?->label() ?? (is_string($state) ? $state : '—');
})
->color(function (mixed $state): string {
$mode = $state instanceof BaselineCaptureMode
? $state
: (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null);
return match ($mode) {
BaselineCaptureMode::FullContent => 'success',
BaselineCaptureMode::Opportunistic => 'warning',
BaselineCaptureMode::MetaOnly => 'gray',
default => 'gray',
};
}),
TextEntry::make('version_label')
->label('Version')
->placeholder('—'),
TextEntry::make('description')
->placeholder('No description')
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
TextEntry::make('scope_jsonb.policy_types')
->label('Policy types')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::policyTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('All supported policy types (excluding foundations)'),
TextEntry::make('scope_jsonb.foundation_types')
->label('Foundations')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::foundationTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('None'),
])
->columnSpanFull(),
Section::make('Baseline truth')
->schema([
TextEntry::make('current_snapshot_truth')
->label('Current snapshot')
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
TextEntry::make('latest_attempted_snapshot_truth')
->label('Latest attempt')
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
TextEntry::make('compare_readiness')
->label('Compare readiness')
->badge()
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
TextEntry::make('baseline_next_step')
->label('Next step')
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Metadata')
->schema([
TextEntry::make('createdByUser.name')
->label('Created by')
->placeholder('—'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
$workspace = self::resolveWorkspace();
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
->sortable(),
TextColumn::make('capture_mode')
->label('Capture mode')
->badge()
->formatStateUsing(function (mixed $state): string {
if ($state instanceof BaselineCaptureMode) {
return $state->label();
}
$parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null;
return $parsed?->label() ?? (is_string($state) ? $state : '—');
})
->color(function (mixed $state): string {
$mode = $state instanceof BaselineCaptureMode
? $state
: (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null);
return match ($mode) {
BaselineCaptureMode::FullContent => 'success',
BaselineCaptureMode::Opportunistic => 'warning',
BaselineCaptureMode::MetaOnly => 'gray',
default => 'gray',
};
})
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('version_label')
->label('Version')
->placeholder('—'),
TextColumn::make('tenant_assignments_count')
->label('Assigned tenants')
->counts('tenantAssignments'),
TextColumn::make('current_snapshot_truth')
->label('Current snapshot')
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
->wrap(),
TextColumn::make('latest_attempted_snapshot_truth')
->label('Latest attempt')
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
->wrap(),
TextColumn::make('compare_readiness')
->label('Compare readiness')
->badge()
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
->wrap(),
TextColumn::make('baseline_next_step')
->label('Next step')
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
->wrap(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
\Filament\Tables\Filters\SelectFilter::make('status')
->options(FilterOptionCatalog::baselineProfileStatuses()),
])
->actions([
Action::make('view')
->label('View')
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
->icon('heroicon-o-eye'),
ActionGroup::make([
Action::make('edit')
->label('Edit')
->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record]))
->icon('heroicon-o-pencil-square')
->visible(fn (): bool => self::hasManageCapability()),
self::archiveTableAction($workspace),
])->label('More'),
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
])
->emptyStateHeading('No baseline profiles')
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
->emptyStateActions([
Action::make('create')
->label('Create baseline profile')
->url(fn (): string => static::getUrl('create'))
->icon('heroicon-o-plus')
->visible(fn (): bool => self::hasManageCapability()),
]);
}
public static function getRelations(): array
{
return [
BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBaselineProfiles::route('/'),
'create' => Pages\CreateBaselineProfile::route('/create'),
'view' => Pages\ViewBaselineProfile::route('/{record}'),
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
];
}
/**
* @return array<string, string>
*/
public static function policyTypeOptions(): array
{
return collect(InventoryPolicyTypeMeta::supported())
->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
])
->sort()
->all();
}
/**
* @return array<string, string>
*/
public static function foundationTypeOptions(): array
{
return collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => InventoryPolicyTypeMeta::baselineCompareLabel((string) $row['type']) ?? (string) ($row['label'] ?? $row['type']),
])
->sort()
->all();
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
$actor = auth()->user();
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: ['metadata' => $metadata],
actor: $actor instanceof User ? $actor : null,
resourceType: 'baseline_profile',
resourceId: (string) $record->getKey(),
);
}
/**
* Status options scoped to valid transitions from the current record state.
*
* @return array<string, string>
*/
private static function statusOptionsForRecord(?BaselineProfile $record): array
{
if ($record === null) {
return [BaselineProfileStatus::Draft->value => BaselineProfileStatus::Draft->label()];
}
$currentStatus = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $currentStatus->selectOptions();
}
private static function resolveWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
private static function hasManageCapability(): bool
{
$user = auth()->user();
$workspace = self::resolveWorkspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
private static function archiveTableAction(?Workspace $workspace): Action
{
$action = Action::make('archive')
->label('Archive')
->icon('heroicon-o-archive-box')
->color('warning')
->requiresConfirmation()
->modalHeading('Archive baseline profile')
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
->hidden(fn (BaselineProfile $record): bool => $record->status === BaselineProfileStatus::Archived)
->action(function (BaselineProfile $record): void {
if (! self::hasManageCapability()) {
throw new AuthorizationException;
}
$record->forceFill(['status' => BaselineProfileStatus::Archived->value])->save();
self::audit($record, AuditActionId::BaselineProfileArchived, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
]);
Notification::make()
->title('Baseline profile archived')
->success()
->send();
});
if ($workspace instanceof Workspace) {
$action = WorkspaceUiEnforcement::forTableAction($action, $workspace)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->destructive()
->apply();
}
return $action;
}
private static function currentSnapshotLabel(BaselineProfile $profile): string
{
$snapshot = self::effectiveSnapshot($profile);
if (! $snapshot instanceof BaselineSnapshot) {
return 'No complete snapshot';
}
return self::snapshotReference($snapshot);
}
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
{
$snapshot = self::effectiveSnapshot($profile);
if (! $snapshot instanceof BaselineSnapshot) {
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
}
return $snapshot->captured_at?->toDayDateTimeString();
}
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
{
$latestAttempt = self::latestAttemptedSnapshot($profile);
if (! $latestAttempt instanceof BaselineSnapshot) {
return 'No capture attempts yet';
}
$effectiveSnapshot = self::effectiveSnapshot($profile);
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
return 'Matches current snapshot';
}
return self::snapshotReference($latestAttempt);
}
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
{
$latestAttempt = self::latestAttemptedSnapshot($profile);
if (! $latestAttempt instanceof BaselineSnapshot) {
return null;
}
$effectiveSnapshot = self::effectiveSnapshot($profile);
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
return 'No newer attempt is pending.';
}
return $latestAttempt->captured_at?->toDayDateTimeString();
}
private static function compareReadinessLabel(BaselineProfile $profile): string
{
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
}
private static function compareReadinessColor(BaselineProfile $profile): string
{
return match (self::compareAvailabilityReason($profile)) {
null => 'success',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
default => 'warning',
};
}
private static function compareReadinessIcon(BaselineProfile $profile): ?string
{
return match (self::compareAvailabilityReason($profile)) {
null => 'heroicon-m-check-badge',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
default => 'heroicon-m-exclamation-triangle',
};
}
private static function profileNextStep(BaselineProfile $profile): string
{
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
}
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
{
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
}
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
{
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
}
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
{
$status = $profile->status instanceof BaselineProfileStatus
? $profile->status
: BaselineProfileStatus::tryFrom((string) $profile->status);
if ($status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
}
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$reasonCode = $resolution['reason_code'] ?? null;
if (is_string($reasonCode) && trim($reasonCode) !== '') {
return trim($reasonCode);
}
if (! self::hasEligibleCompareTarget($profile)) {
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
}
return null;
}
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
{
$reasonCode = self::compareAvailabilityReason($profile);
if (! is_string($reasonCode)) {
return null;
}
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
}
private static function snapshotReference(BaselineSnapshot $snapshot): string
{
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
}
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenantIds = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->pluck('tenant_id')
->all();
if ($tenantIds === []) {
return false;
}
$resolver = app(CapabilityResolver::class);
return Tenant::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('id', $tenantIds)
->get(['id'])
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateBaselineProfile extends CreateRecord
{
protected static string $resource = BaselineProfileResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$data['workspace_id'] = (int) $workspaceId;
$user = auth()->user();
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
if (isset($data['scope_jsonb'])) {
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
}
return $data;
}
protected function afterCreate(): void
{
$record = $this->record;
if (! $record instanceof BaselineProfile) {
return;
}
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'status' => $record->status instanceof BaselineProfileStatus
? $record->status->value
: (string) $record->status,
]);
Notification::make()
->title('Baseline profile created')
->success()
->send();
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditBaselineProfile extends EditRecord
{
protected static string $resource = BaselineProfileResource::class;
public function getSubHeading(): string
{
$record = $this->getRecord();
$status = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $status->label();
}
public function getSubHeadingBadgeColor(): string
{
$record = $this->getRecord();
$status = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $status->color();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
$record = $this->getRecord();
$currentStatus = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
if ($currentStatus === BaselineProfileStatus::Archived) {
unset($data['status']);
}
if (isset($data['scope_jsonb'])) {
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
}
return $data;
}
protected function afterSave(): void
{
$record = $this->record;
if (! $record instanceof BaselineProfile) {
return;
}
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'status' => $record->status instanceof BaselineProfileStatus
? $record->status->value
: (string) $record->status,
]);
Notification::make()
->title('Baseline profile updated')
->success()
->send();
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]);
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListBaselineProfiles extends ListRecords
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate())
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
];
}
protected function getTableEmptyStateActions(): array
{
return [
CreateAction::make()
->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
];
}
}

View File

@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
class ViewBaselineProfile extends ViewRecord
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('view_active_snapshot')
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
->color('gray'),
$this->captureAction(),
$this->compareNowAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
];
}
private function activeSnapshotEntry(): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
}
private function captureAction(): Action
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
: BaselineCaptureMode::Opportunistic;
$label = $captureMode === BaselineCaptureMode::FullContent
? 'Capture baseline (full content)'
: 'Capture baseline';
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
? 'Select the source tenant. This will capture content evidence on demand (redacted) and may take longer depending on scope.'
: 'Select the source tenant whose current inventory will be captured as the baseline snapshot.';
$action = Action::make('capture')
->label($label)
->icon('heroicon-o-camera')
->color('primary')
->requiresConfirmation()
->modalHeading($label)
->modalDescription($modalDescription)
->form([
Select::make('source_tenant_id')
->label('Source Tenant')
->options(fn (): array => $this->getWorkspaceTenantOptions())
->required()
->searchable(),
])
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
if (! $sourceTenant instanceof Tenant) {
Notification::make()
->title('Source tenant not found')
->danger()
->send();
return;
}
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $sourceTenant, $user);
if (! $result['ok']) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$message = match ($reasonCode) {
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make()
->title('Cannot start capture')
->body($message)
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if (! $run instanceof \App\Models\OperationRun) {
Notification::make()
->title('Cannot start capture')
->body('Reason: missing operation run')
->danger()
->send();
return;
}
$viewAction = Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $sourceTenant));
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
->actions([$viewAction])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $run->type)
->actions([$viewAction])
->send();
});
return WorkspaceUiEnforcement::forTableAction($action, fn (): ?Workspace => Workspace::query()
->whereKey((int) $this->getRecord()->workspace_id)
->first())
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->apply();
}
private function compareNowAction(): Action
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
: BaselineCaptureMode::Opportunistic;
$label = $captureMode === BaselineCaptureMode::FullContent
? 'Compare now (full content)'
: 'Compare now';
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
return Action::make('compareNow')
->label($label)
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading($label)
->modalDescription($modalDescription)
->form([
Select::make('target_tenant_id')
->label('Target Tenant')
->options(fn (): array => $this->getEligibleCompareTenantOptions())
->required()
->searchable(),
])
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$targetTenant = Tenant::query()->find((int) $data['target_tenant_id']);
if (! $targetTenant instanceof Tenant || (int) $targetTenant->workspace_id !== (int) $profile->workspace_id) {
Notification::make()
->title('Target tenant not found')
->danger()
->send();
return;
}
$assignment = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('tenant_id', (int) $targetTenant->getKey())
->where('baseline_profile_id', (int) $profile->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
Notification::make()
->title('Tenant not assigned')
->body('This tenant is not assigned to this baseline profile.')
->warning()
->send();
return;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_SYNC)) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
$service = app(BaselineCompareService::class);
$result = $service->startCompare($targetTenant, $user);
if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$message = match ($reasonCode) {
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make()
->title('Cannot start comparison')
->body($message)
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if (! $run instanceof \App\Models\OperationRun) {
Notification::make()
->title('Cannot start comparison')
->body('Reason: missing operation run')
->danger()
->send();
return;
}
$viewAction = Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $targetTenant));
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
->actions([$viewAction])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $run->type)
->actions([$viewAction])
->send();
});
}
/**
* @return array<int, string>
*/
private function getWorkspaceTenantOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return [];
}
return Tenant::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<int, string>
*/
private function getEligibleCompareTenantOptions(): array
{
$user = auth()->user();
if (! $user instanceof User) {
return [];
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$tenantIds = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->pluck('tenant_id')
->all();
if ($tenantIds === []) {
return [];
}
$resolver = app(CapabilityResolver::class);
$options = [];
$tenants = Tenant::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('id', $tenantIds)
->orderBy('name')
->get(['id', 'name']);
foreach ($tenants as $tenant) {
if (! $tenant instanceof Tenant) {
continue;
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
continue;
}
$options[(int) $tenant->getKey()] = (string) $tenant->name;
}
return $options;
}
private function hasManageCapability(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
private function profileHasConsumableSnapshot(): bool
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return $profile->resolveCurrentConsumableSnapshot() !== null;
}
}

View File

@ -0,0 +1,329 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class BaselineTenantAssignmentsRelationManager extends RelationManager
{
protected static string $relationship = 'tenantAssignments';
protected static ?string $title = 'Tenant assignments';
/**
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
*/
protected ?array $tenantAssignmentSummaries = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.');
}
public function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant')
->searchable(),
Tables\Columns\TextColumn::make('assignedByUser.name')
->label('Assigned by')
->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')
->label('Assigned at')
->dateTime()
->sortable(),
])
->headerActions([
$this->assignTenantAction()
->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()),
])
->actions([
$this->removeAssignmentAction(),
])
->emptyStateHeading('No tenants assigned')
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
->emptyStateActions([
$this->assignTenantAction()->name('assignEmpty'),
]);
}
private function assignTenantAction(): Action
{
return Action::make('assign')
->label('Assign Tenant')
->icon('heroicon-o-plus')
->visible(fn (): bool => $this->hasManageCapability())
->form([
Select::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->getTenantOptions())
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
->required()
->searchable(),
])
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$tenantId = (int) $data['tenant_id'];
$existing = BaselineTenantAssignment::query()
->where('workspace_id', $profile->workspace_id)
->where('tenant_id', $tenantId)
->first();
if ($existing instanceof BaselineTenantAssignment) {
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
Notification::make()
->title('Tenant already assigned')
->body($assignedBaselineName === null
? 'This tenant already has a baseline assignment in this workspace.'
: "This tenant is already assigned to baseline: {$assignedBaselineName}.")
->warning()
->send();
return;
}
$assignment = BaselineTenantAssignment::create([
'workspace_id' => (int) $profile->workspace_id,
'tenant_id' => $tenantId,
'baseline_profile_id' => (int) $profile->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]);
$this->auditAssignment($profile, $assignment, $user, 'created');
Notification::make()
->title('Tenant assigned')
->success()
->send();
$this->forgetTenantAssignmentSummaries();
});
}
private function removeAssignmentAction(): Action
{
return Action::make('remove')
->label('Remove')
->icon('heroicon-o-trash')
->color('danger')
->visible(fn (): bool => $this->hasManageCapability())
->requiresConfirmation()
->modalHeading('Remove tenant assignment')
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
->action(function (BaselineTenantAssignment $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$this->auditAssignment($profile, $record, $user, 'removed');
$record->delete();
Notification::make()
->title('Assignment removed')
->success()
->send();
$this->forgetTenantAssignmentSummaries();
});
}
/**
* @return array<int, string>
*/
private function getTenantOptions(): array
{
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$assignmentSummaries = $this->getTenantAssignmentSummaries();
return Tenant::query()
->where('workspace_id', $profile->workspace_id)
->orderBy('name')
->get(['id', 'name'])
->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array {
$tenantId = (int) $tenant->getKey();
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
return [
$tenantId => $this->formatTenantOptionLabel($tenant, $assignmentSummary),
];
})
->all();
}
/**
* @return array<int, array{baseline_profile_id:int, baseline_profile_name:string}>
*/
private function getTenantAssignmentSummaries(): array
{
if (is_array($this->tenantAssignmentSummaries)) {
return $this->tenantAssignmentSummaries;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->with('baselineProfile:id,name')
->get(['tenant_id', 'baseline_profile_id'])
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
$baselineProfile = $assignment->baselineProfile;
return [
(int) $assignment->tenant_id => [
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
? (string) $baselineProfile->name
: 'another baseline profile',
],
];
})
->all();
return $this->tenantAssignmentSummaries;
}
/**
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
*/
private function formatTenantOptionLabel(
Tenant $tenant,
?array $assignmentSummary,
): string {
$tenantName = (string) $tenant->name;
if ($assignmentSummary === null) {
return $tenantName;
}
return "{$tenantName} (assigned to baseline: {$assignmentSummary['baseline_profile_name']})";
}
private function getAssignedBaselineNameForTenant(int $tenantId): ?string
{
$assignmentSummary = $this->getTenantAssignmentSummaries()[$tenantId] ?? null;
if ($assignmentSummary === null) {
return null;
}
return $assignmentSummary['baseline_profile_name'];
}
private function forgetTenantAssignmentSummaries(): void
{
$this->tenantAssignmentSummaries = null;
}
private function auditAssignment(
BaselineProfile $profile,
BaselineTenantAssignment $assignment,
User $user,
string $action,
): void {
$workspace = Workspace::query()->find($profile->workspace_id);
if (! $workspace instanceof Workspace) {
return;
}
$tenant = Tenant::query()->find($assignment->tenant_id);
$auditLogger = app(WorkspaceAuditLogger::class);
$auditLogger->log(
workspace: $workspace,
action: 'baseline.assignment.'.$action,
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
'tenant_id' => (int) $assignment->tenant_id,
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
],
actor: $user,
resourceType: 'baseline_profile',
resourceId: (string) $profile->getKey(),
);
}
private function hasManageCapability(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
}

View File

@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Filament\FilterPresets;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class BaselineSnapshotResource extends Resource
{
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $model = BaselineSnapshot::class;
protected static ?string $slug = 'baseline-snapshots';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-camera';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baseline Snapshots';
protected static ?int $navigationSort = 2;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = self::resolveWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function canView(Model $record): bool
{
if (! $record instanceof BaselineSnapshot) {
return false;
}
$workspace = self::resolveWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
return self::canViewAny()
&& (int) $record->workspace_id === (int) $workspace->getKey();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation is surfaced in the view header.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with('baselineProfile')
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function table(Table $table): Table
{
return $table
->defaultSort('captured_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
TextColumn::make('id')
->label('Snapshot')
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—')
->sortable(),
TextColumn::make('baselineProfile.name')
->label('Baseline')
->wrap()
->searchable()
->placeholder('—'),
TextColumn::make('captured_at')
->label('Captured')
->since()
->sortable(),
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
->wrap(),
TextColumn::make('lifecycle_state')
->label('Lifecycle')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
->sortable(),
TextColumn::make('current_truth')
->label('Current truth')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
->wrap(),
TextColumn::make('fidelity_summary')
->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
->wrap(),
])
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->filters([
SelectFilter::make('baseline_profile_id')
->label('Baseline')
->options(static::baselineProfileOptions())
->searchable(),
SelectFilter::make('lifecycle_state')
->label('Lifecycle')
->options(static::lifecycleOptions())
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
])
->actions([
static::primaryRelatedAction(),
])
->bulkActions([])
->emptyStateHeading('No baseline snapshots')
->emptyStateDescription('Capture a baseline snapshot to review evidence fidelity and compare tenants over time.')
->emptyStateIcon('heroicon-o-camera');
}
public static function infolist(Schema $schema): Schema
{
return $schema;
}
private static function primaryRelatedAction(): Action
{
return Action::make('primary_drill_down')
->label(fn (BaselineSnapshot $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
->url(fn (BaselineSnapshot $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
->hidden(fn (BaselineSnapshot $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
->color('gray');
}
private static function primaryRelatedEntry(BaselineSnapshot $record): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $record);
}
public static function getPages(): array
{
return [
'index' => Pages\ListBaselineSnapshots::route('/'),
'view' => Pages\ViewBaselineSnapshot::route('/{record}'),
];
}
/**
* @return array<int, string>
*/
private static function baselineProfileOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return [];
}
return BaselineProfile::query()
->where('workspace_id', (int) $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<string, string>
*/
private static function lifecycleOptions(): array
{
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
}
public static function resolveWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
private static function summary(BaselineSnapshot $snapshot): array
{
return is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
}
private static function fidelityCounts(BaselineSnapshot $snapshot): array
{
$summary = self::summary($snapshot);
$counts = $summary['fidelity_counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$content = $counts['content'] ?? 0;
$meta = $counts['meta'] ?? 0;
return [
'content' => is_numeric($content) ? (int) $content : 0,
'meta' => is_numeric($meta) ? (int) $meta : 0,
];
}
private static function fidelitySummary(BaselineSnapshot $snapshot): string
{
$counts = self::fidelityCounts($snapshot);
return sprintf(
'%s %d, %s %d',
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
(int) ($counts['content'] ?? 0),
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
(int) ($counts['meta'] ?? 0),
);
}
private static function gapsCount(BaselineSnapshot $snapshot): int
{
$summary = self::summary($snapshot);
$gaps = $summary['gaps'] ?? null;
$gaps = is_array($gaps) ? $gaps : [];
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
if ($byReason !== []) {
return array_sum(array_map(
static fn (mixed $count, string $reason): int => in_array($reason, ['meta_fallback'], true) || ! is_numeric($count)
? 0
: (int) $count,
$byReason,
array_keys($byReason),
));
}
$count = $gaps['count'] ?? 0;
return is_numeric($count) ? (int) $count : 0;
}
private static function hasGaps(BaselineSnapshot $snapshot): bool
{
return self::gapsCount($snapshot) > 0;
}
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
}
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || trim($value) === '') {
return $query;
}
return $query->where('lifecycle_state', trim($value));
}
private static function gapCountExpression(Builder $query): string
{
return match ($query->getConnection()->getDriverName()) {
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
};
}
private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{
return BadgeCatalog::spec(
BadgeDomain::BaselineSnapshotGapStatus,
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
);
}
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
}
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'Current baseline',
'historical' => 'Historical trace',
default => 'Not compare input',
};
}
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
default => self::truthEnvelope($snapshot)->primaryExplanation,
};
}
private static function currentTruthColor(BaselineSnapshot $snapshot): string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'success',
'historical' => 'gray',
default => 'warning',
};
}
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'heroicon-m-check-badge',
'historical' => 'heroicon-m-clock',
default => 'heroicon-m-exclamation-triangle',
};
}
private static function currentTruthState(BaselineSnapshot $snapshot): string
{
if (! $snapshot->isConsumable()) {
return 'unusable';
}
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
? 'historical'
: 'current';
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use Filament\Resources\Pages\ListRecords;
class ListBaselineSnapshots extends ListRecords
{
protected static string $resource = BaselineSnapshotResource::class;
protected function authorizeAccess(): void
{
$user = auth()->user();
$workspace = BaselineSnapshotResource::resolveWorkspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\SnapshotRendering\BaselineSnapshotPresenter;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use Filament\Actions\Action;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Schema;
class ViewBaselineSnapshot extends ViewRecord
{
protected static string $resource = BaselineSnapshotResource::class;
/**
* @var array<string, mixed>
*/
public array $enterpriseDetail = [];
public function mount(int|string $record): void
{
parent::mount($record);
$snapshot = $this->getRecord();
if ($snapshot instanceof BaselineSnapshot) {
$snapshot->loadMissing(['baselineProfile', 'items']);
$relatedContext = app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
$this->enterpriseDetail = app(BaselineSnapshotPresenter::class)
->presentEnterpriseDetail($snapshot, $relatedContext)
->toArray();
}
}
protected function authorizeAccess(): void
{
$user = auth()->user();
$snapshot = $this->getRecord();
$workspace = BaselineSnapshotResource::resolveWorkspace();
if (! $user instanceof User || ! $snapshot instanceof BaselineSnapshot || ! $workspace instanceof Workspace) {
abort(404);
}
if ((int) $snapshot->workspace_id !== (int) $workspace->getKey()) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
public function infolist(Schema $schema): Schema
{
return $schema
->schema([
ViewEntry::make('enterprise_detail')
->label('')
->view('filament.infolists.entries.enterprise-detail.layout')
->state(fn (): array => $this->enterpriseDetail)
->columnSpanFull(),
]);
}
protected function getHeaderActions(): array
{
return [
Action::make('primary_related')
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
->color('gray'),
];
}
private function primaryRelatedEntry(): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $this->getRecord())[0] ?? null;
}
}

View File

@ -2,36 +2,76 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\EntraGroupResource\Pages;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Facades\Filament;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use UnitEnum;
class EntraGroupResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroup::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static ?string $recordTitleAttribute = 'display_name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $navigationLabel = 'Groups';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'No canonical related destination exists for directory groups yet.');
}
public static function form(Schema $schema): Schema
{
return $schema;
@ -41,40 +81,10 @@ public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Group')
->schema([
TextEntry::make('display_name')->label('Name'),
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
TextEntry::make('type')
->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
TextEntry::make('security_enabled')
->label('Security')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
TextEntry::make('mail_enabled')
->label('Mail')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Raw groupTypes')
->schema([
ViewEntry::make('group_types')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EntraGroup $record) => $record->group_types ?? [])
->columnSpanFull(),
])
ViewEntry::make('enterprise_detail')
->label('')
->view('filament.infolists.entries.enterprise-detail.layout')
->state(fn (EntraGroup $record): array => static::enterpriseDetailPage($record)->toArray())
->columnSpanFull(),
]);
}
@ -83,20 +93,30 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('display_name')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
? static::scopedUrl('view', ['record' => $record], static::panelTenantContext())
: null)
->columns([
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(),
Tables\Columns\TextColumn::make('display_name')
->label('Name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('entra_id')
->label('Entra ID')
->copyable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('type')
->label('Type')
->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'),
Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
])
->filters([
SelectFilter::make('stale')
@ -124,7 +144,6 @@ public static function table(Table $table): Table
return $query->where('last_seen_at', '>=', $cutoff);
}),
SelectFilter::make('group_type')
->label('Type')
->options([
@ -165,15 +184,31 @@ public static function table(Table $table): Table
};
}),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
->actions([])
->bulkActions([])
->emptyStateHeading('No groups cached yet')
->emptyStateDescription('Sync groups for the current tenant to browse directory data here.')
->emptyStateIcon('heroicon-o-user-group');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->latest('id');
return static::getTenantOwnedEloquentQuery()
->latest('id');
}
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key);
}
public static function getGlobalSearchResultUrl(Model $record): string
{
$tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant
? $record->tenant
: static::panelTenantContext();
return static::scopedUrl('view', ['record' => $record], $tenant);
}
public static function getPages(): array
@ -184,6 +219,20 @@ public static function getPages(): array
];
}
/**
* @param array<string, mixed> $parameters
*/
public static function scopedUrl(
string $page = 'index',
array $parameters = [],
?Tenant $tenant = null,
?string $panel = null,
): string {
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
}
private static function groupType(EntraGroup $record): string
{
$groupTypes = $record->group_types;
@ -222,4 +271,105 @@ private static function groupTypeColor(string $type): string
default => 'gray',
};
}
private static function enterpriseDetailPage(EntraGroup $record): EnterpriseDetailPageData
{
$factory = new EnterpriseDetailSectionFactory;
$groupType = static::groupType($record);
$groupTypeLabel = static::groupTypeLabel($groupType);
$groupTypeBadge = $factory->statusBadge($groupTypeLabel, static::groupTypeColor($groupType));
$securityBadge = $factory->statusBadge(
BadgeRenderer::label(BadgeDomain::BooleanEnabled)($record->security_enabled),
BadgeRenderer::color(BadgeDomain::BooleanEnabled)($record->security_enabled),
BadgeRenderer::icon(BadgeDomain::BooleanEnabled)($record->security_enabled),
BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)($record->security_enabled),
);
$mailBadge = $factory->statusBadge(
BadgeRenderer::label(BadgeDomain::BooleanEnabled)($record->mail_enabled),
BadgeRenderer::color(BadgeDomain::BooleanEnabled)($record->mail_enabled),
BadgeRenderer::icon(BadgeDomain::BooleanEnabled)($record->mail_enabled),
BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)($record->mail_enabled),
);
$technicalPayload = [
'entra_id' => $record->entra_id,
'group_types' => is_array($record->group_types) ? $record->group_types : [],
];
return EnterpriseDetailBuilder::make('entra_group', 'tenant')
->header(new SummaryHeaderData(
title: (string) $record->display_name,
subtitle: 'Directory group #'.$record->getKey(),
statusBadges: [$groupTypeBadge, $securityBadge, $mailBadge],
keyFacts: [
$factory->keyFact('Type', $groupTypeLabel, badge: $groupTypeBadge),
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
$factory->keyFact('Security enabled', $record->security_enabled, badge: $securityBadge),
$factory->keyFact('Mail enabled', $record->mail_enabled, badge: $mailBadge),
],
descriptionHint: 'Group identity and classification stay ahead of provider-oriented metadata.',
))
->addSection(
$factory->factsSection(
id: 'classification_overview',
kind: 'core_details',
title: 'Classification overview',
items: [
$factory->keyFact('Type', $groupTypeLabel, badge: $groupTypeBadge),
$factory->keyFact('Security enabled', $record->security_enabled, badge: $securityBadge),
$factory->keyFact('Mail enabled', $record->mail_enabled, badge: $mailBadge),
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
],
),
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => []],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'summary',
title: 'Directory identity',
items: [
$factory->keyFact('Display name', $record->display_name),
$factory->keyFact('Entra ID', $record->entra_id),
],
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Freshness',
items: [
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
$factory->keyFact('Cached group types', count($technicalPayload['group_types'])),
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Technical detail',
entries: [
$factory->keyFact('Entra ID', $record->entra_id),
$factory->keyFact('Cached group types', count($technicalPayload['group_types'])),
],
description: 'Provider identifiers and raw group-type arrays stay secondary to group identity and classification.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $technicalPayload],
),
)
->build();
}
private static function formatDetailTimestamp(mixed $value): string
{
if (! $value instanceof Carbon) {
return '—';
}
return $value->toDayDateTimeString();
}
}

View File

@ -3,40 +3,61 @@
namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords
{
protected static string $resource = EntraGroupResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
if (
Filament::getCurrentPanel()?->getId() === 'admin'
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
) {
abort(404);
}
parent::mount();
}
protected function getHeaderActions(): array
{
$tenant = EntraGroupResource::panelTenantContext();
return [
Action::make('view_group_sync_runs')
->label('Group Sync Runs')
Action::make('view_operations')
->label('Operations')
->icon('heroicon-o-clock')
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()),
->url(fn (): string => OperationRunLinks::index($tenant))
->visible(fn (): bool => $tenant instanceof Tenant),
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->color('primary')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
$tenant = EntraGroupResource::panelTenantContext();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
@ -47,18 +68,20 @@ protected function getHeaderActions(): array
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => $selectionKey],
initiator: $user
type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View Run')
@ -70,55 +93,21 @@ protected function getHeaderActions(): array
}
// ----------------------------------------------
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
runId: null,
operationRun: $opRun
));
Notification::make()
->title('Group sync started')
->body('Sync dispatched.')
->success()
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
})
)

View File

@ -3,9 +3,49 @@
namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewEntraGroup extends ViewRecord
{
protected static string $resource = EntraGroupResource::class;
protected function resolveRecord(int|string $key): Model
{
return EntraGroupResource::resolveScopedRecordOrFail($key);
}
protected function authorizeAccess(): void
{
$tenant = EntraGroupResource::panelTenantContext();
$record = $this->getRecord();
$user = auth()->user();
if (
Filament::getCurrentPanel()?->getId() === 'admin'
&& ! $tenant instanceof Tenant
) {
abort(404);
}
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof EntraGroup) {
abort(404);
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $user->can('view', $record)) {
abort(403);
}
}
}

View File

@ -1,154 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class EntraGroupSyncRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroupSyncRun::class;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $navigationLabel = 'Group Sync Runs';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Legacy run view')
->description('Canonical monitoring is now available in Monitoring → Operations.')
->schema([
TextEntry::make('canonical_view')
->label('Canonical view')
->state('View in Operations')
->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
->badge()
->color('primary'),
])
->columnSpanFull(),
Section::make('Sync Run')
->schema([
TextEntry::make('initiator.name')
->label('Initiator')
->placeholder('—'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
TextEntry::make('selection_key')->label('Selection'),
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(),
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('error_count')->label('Errors')->numeric(),
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Error Summary')
->schema([
TextEntry::make('error_code')->placeholder('—'),
TextEntry::make('error_category')->placeholder('—'),
ViewEntry::make('error_summary')
->label('Safe error summary')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->columns([
Tables\Columns\TextColumn::make('initiator.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
Tables\Columns\TextColumn::make('selection_key')
->label('Selection')
->limit(24)
->copyable(),
Tables\Columns\TextColumn::make('slot_key')
->label('Slot')
->placeholder('—')
->limit(16)
->copyable(),
Tables\Columns\TextColumn::make('started_at')->since(),
Tables\Columns\TextColumn::make('finished_at')->since(),
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('initiator')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
];
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroupSyncRuns extends ListRecords
{
protected static string $resource = EntraGroupSyncRunResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
$selectionKey = EntraGroupSelection::allGroupsV1();
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $existing->getKey(),
'status' => $normalizedStatus,
]));
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
));
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use Filament\Resources\Pages\ViewRecord;
class ViewEntraGroupSyncRun extends ViewRecord
{
protected static string $resource = EntraGroupSyncRunResource::class;
}

View File

@ -0,0 +1,676 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Panel;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use UnitEnum;
class EvidenceSnapshotResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = EvidenceSnapshot::class;
protected static ?string $slug = 'evidence';
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Evidence';
protected static ?int $navigationSort = 55;
public static function canViewAny(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return false;
}
return ! $record instanceof EvidenceSnapshot
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
}
public static function resolveScopedRecordOrFail(int|string|null $record): Model
{
return static::resolveTenantOwnedRecordOrFail($record);
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Snapshot')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
TextEntry::make('completeness_state')
->label('Completeness')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
TextEntry::make('operationRun.id')
->label('Operation run')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
->openUrlInNewTab(),
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
])
->columns(2),
Section::make('Summary')
->schema([
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
])
->columns(2),
Section::make('Evidence dimensions')
->schema([
RepeatableEntry::make('items')
->hiddenLabel()
->schema([
TextEntry::make('dimension_key')->label('Dimension')
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('state')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
TextEntry::make('source_kind')->label('Source')
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
ViewEntry::make('summary_payload_highlights')
->label('Summary')
->view('filament.infolists.entries.evidence-dimension-summary')
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
->columnSpanFull(),
ViewEntry::make('summary_payload_raw')
->label('Raw summary JSON')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
->columnSpanFull(),
])
->columns(4),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('completeness_state')
->label('Completeness')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
->sortable(),
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
Tables\Filters\SelectFilter::make('completeness_state')
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
])
->actions([
Actions\Action::make('view_snapshot')
->label('View snapshot')
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
UiEnforcement::forTableAction(
Actions\Action::make('expire')
->label('Expire snapshot')
->color('danger')
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
->requiresConfirmation()
->action(function (EvidenceSnapshot $record): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(EvidenceSnapshotService::class)->expire($record, $user);
Notification::make()->success()->title('Snapshot expired')->send();
}),
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
)
->preserveVisibility()
->requireCapability(Capabilities::EVIDENCE_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
])
->bulkActions([])
->emptyStateHeading('No evidence snapshots yet')
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
->emptyStateActions([
UiEnforcement::forAction(
Actions\Action::make('create_first_snapshot')
->label('Create first snapshot')
->icon('heroicon-o-plus')
->action(fn (): mixed => static::executeGeneration([])),
)
->requireCapability(Capabilities::EVIDENCE_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvidenceSnapshots::route('/'),
'view' => new PageRegistration(
page: Pages\ViewEvidenceSnapshot::class,
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
->whereNumber('record')
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
),
];
}
/**
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
{
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
return match ($item->dimension_key) {
'findings_summary' => static::findingsSummaryPresentation($payload),
'permission_posture' => static::permissionPosturePresentation($payload),
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
'operations_summary' => static::operationsSummaryPresentation($payload),
default => static::genericSummaryPresentation($payload),
};
}
/**
* @param array<string, mixed> $payload
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function findingsSummaryPresentation(array $payload): array
{
$count = (int) ($payload['count'] ?? 0);
$openCount = (int) ($payload['open_count'] ?? 0);
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
return [
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
'highlights' => [
['label' => 'Findings', 'value' => (string) $count],
['label' => 'Open findings', 'value' => (string) $openCount],
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
],
'items' => collect($entries)
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
->filter()
->take(5)
->values()
->all(),
];
}
/**
* @param array<string, mixed> $payload
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function permissionPosturePresentation(array $payload): array
{
$requiredCount = (int) ($payload['required_count'] ?? 0);
$grantedCount = (int) ($payload['granted_count'] ?? 0);
$postureScore = $payload['posture_score'] ?? null;
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
return [
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
'highlights' => [
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
['label' => 'Required permissions', 'value' => (string) $requiredCount],
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
],
'items' => static::namedItemsFromArray(
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
'No missing permission details captured.'
),
];
}
/**
* @param array<string, mixed> $payload
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function entraAdminRolesPresentation(array $payload): array
{
$roleCount = (int) ($payload['role_count'] ?? 0);
return [
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
'highlights' => [
['label' => 'Role count', 'value' => (string) $roleCount],
],
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
];
}
/**
* @param array<string, mixed> $payload
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function baselineDriftPosturePresentation(array $payload): array
{
$driftCount = (int) ($payload['drift_count'] ?? 0);
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
return [
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
'highlights' => [
['label' => 'Drift findings', 'value' => (string) $driftCount],
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
],
'items' => [],
];
}
/**
* @param array<string, mixed> $payload
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function operationsSummaryPresentation(array $payload): array
{
$operationCount = (int) ($payload['operation_count'] ?? 0);
$failedCount = (int) ($payload['failed_count'] ?? 0);
$partialCount = (int) ($payload['partial_count'] ?? 0);
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
$actionSummary = $failedCount === 0 && $partialCount === 0
? 'No action needed.'
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
return [
'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
'highlights' => [
['label' => 'Operations', 'value' => (string) $operationCount],
['label' => 'Execution failures', 'value' => (string) $failedCount],
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
],
'items' => collect($entries)
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
->filter()
->take(5)
->values()
->all(),
];
}
/**
* @param array<string, mixed> $payload
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
*/
private static function genericSummaryPresentation(array $payload): array
{
$highlights = collect($payload)
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
->take(6)
->map(fn (mixed $value, string|int $key): array => [
'label' => Str::headline((string) $key),
'value' => static::stringifySummaryValue($value),
])
->values()
->all();
return [
'summary' => empty($highlights) ? 'No summary details captured.' : null,
'highlights' => $highlights,
'items' => [],
];
}
/**
* @return list<string>
*/
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
{
if (! is_array($items) || $items === []) {
return [$emptyFallback];
}
$labels = collect($items)
->map(function (mixed $item): ?string {
if (is_string($item)) {
return trim($item) !== '' ? $item : null;
}
if (! is_array($item)) {
return null;
}
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
$value = $item[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return null;
})
->filter()
->take(5)
->values()
->all();
return $labels === [] ? [$emptyFallback] : $labels;
}
/**
* @param array<string, mixed> $entry
*/
private static function findingEntryLabel(array $entry): ?string
{
$title = $entry['title'] ?? null;
$severity = $entry['severity'] ?? null;
$status = $entry['status'] ?? null;
if (! is_string($title) || trim($title) === '') {
return null;
}
$parts = [trim($title)];
if (is_string($severity) && trim($severity) !== '') {
$parts[] = Str::headline($severity);
}
if (is_string($status) && trim($status) !== '') {
$parts[] = Str::headline($status);
}
return implode(' · ', $parts);
}
/**
* @param array<string, mixed> $entry
*/
private static function operationEntryLabel(array $entry): ?string
{
$type = $entry['type'] ?? null;
if (! is_string($type) || trim($type) === '') {
return null;
}
$parts = [static::operationTypeLabel($type)];
$stateLabel = static::operationEntryStateLabel($entry);
if ($stateLabel !== null) {
$parts[] = $stateLabel;
}
return implode(' · ', $parts);
}
public static function canExpireRecord(EvidenceSnapshot $record): bool
{
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
}
private static function operationTypeLabel(string $type): string
{
$label = OperationCatalog::label($type);
return $label === 'Unknown operation' ? 'Operation' : $label;
}
/**
* @param array<string, mixed> $entry
*/
private static function operationEntryStateLabel(array $entry): ?string
{
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
return match ($status) {
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
OperationRunStatus::Completed->value => match ($outcome) {
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
OperationRunOutcome::Succeeded->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Blocked->value,
OperationRunOutcome::Failed->value,
OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
},
default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
};
}
private static function evidenceCompletenessCountLabel(string $state): string
{
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
}
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
{
if ($state === null || trim($state) === '') {
return null;
}
$label = BadgeCatalog::spec($domain, $state)->label;
return $label === 'Unknown' ? null : $label;
}
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
}
private static function stringifySummaryValue(mixed $value): string
{
return match (true) {
$value === null => '—',
is_bool($value) => $value ? 'Yes' : 'No',
is_scalar($value) => (string) $value,
default => '—',
};
}
/**
* @param array<string, mixed> $data
*/
public static function executeGeneration(array $data): void
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
return;
}
$snapshot = app(EvidenceSnapshotService::class)->generate(
tenant: $tenant,
user: $user,
allowStale: (bool) ($data['allow_stale'] ?? false),
);
if (! $snapshot->wasRecentlyCreated) {
Notification::make()
->success()
->title('Snapshot already available')
->body('A matching active snapshot already exists. No new run was started.')
->actions([
Actions\Action::make('view_snapshot')
->label('View snapshot')
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
])
->send();
return;
}
Notification::make()
->success()
->title('Create snapshot queued')
->body('The snapshot is being generated in the background.')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
])
->send();
}
}

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