Compare commits

..

27 Commits

Author SHA1 Message Date
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
990 changed files with 95960 additions and 3067 deletions

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

@ -63,6 +63,43 @@ ## Active Technologies
- 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 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; 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) - 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 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -82,8 +119,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 134-audit-log-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 133-detail-page-template: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 158-artifact-truth-semantics: Added 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
- 132-guid-context-resolver: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- 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.

1
.gitignore vendored
View File

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

View File

@ -1,17 +1,19 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.10.0 → 1.11.0 - Version change: 1.11.0 → 1.12.0
- Modified principles: - Modified principles:
- None - None
- Added sections: - Added sections:
- Operator-facing UI Naming Standards (UI-NAMING-001) - Operator Surface Principles (OPSURF-001)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md - ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present in this repo) - ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md
- Follow-up TODOs: - Follow-up TODOs:
- None. - None.
--> -->
@ -68,6 +70,7 @@ ### Tenant Isolation is Non-negotiable
- Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id. - 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: 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: 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 & UI Enforcement Standards (RBAC-UX)
@ -332,6 +335,65 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
- The visible run label for that action MUST be `Policy sync`. - The visible run label for that action MUST be `Policy sync`.
- The audit prose for that action MUST be `{actor} queued 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) Spec Scope Fields (SCOPE-002)
- Every feature spec MUST declare: - Every feature spec MUST declare:
@ -389,4 +451,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10 **Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21

View File

@ -50,6 +50,12 @@ ## Constitution Check
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - 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 - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- 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 - 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 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 - 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 ## Project Structure

View File

@ -17,6 +17,14 @@ ## Spec Scope Fields *(mandatory)*
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant] - **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks] - **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)* ## User Scenarios & Testing *(mandatory)*
<!-- <!--
@ -127,6 +135,15 @@ ## Requirements *(mandatory)*
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose, - 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. - 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, **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. 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. If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.

View File

@ -38,6 +38,13 @@ # Tasks: [FEATURE NAME]
- using source/domain terms only where same-screen disambiguation is required, - 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, - 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. - 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: **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, - 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), - implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),

View File

@ -681,7 +681,7 @@ ## 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. 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.1 - php - 8.4.15
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0

View File

@ -521,7 +521,7 @@ ## 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. 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.1 - php - 8.4.15
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0

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

@ -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,6 +6,7 @@
use BackedEnum; use BackedEnum;
use Filament\Clusters\Cluster; use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Pages\Enums\SubNavigationPosition; use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum; use UnitEnum;
@ -18,4 +19,13 @@ class InventoryCluster extends Cluster
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
protected static ?string $navigationLabel = 'Items'; 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,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; namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -19,6 +22,10 @@ public static function getGlobalSearchEloquentQuery(): Builder
{ {
$query = static::getModel()::query(); $query = static::getModel()::query();
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
return $query->whereRaw('1 = 0');
}
if (! static::isScopedToTenant()) { if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel(); $panel = Filament::getCurrentOrDefaultPanel();
@ -27,7 +34,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
} }
} }
$tenant = Filament::getTenant(); $tenant = static::resolveGlobalSearchTenant();
if (! $tenant instanceof Model) { if (! $tenant instanceof Model) {
return $query->whereRaw('1 = 0'); return $query->whereRaw('1 = 0');
@ -41,4 +48,17 @@ public static function getGlobalSearchEloquentQuery(): Builder
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship); 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

@ -4,6 +4,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -28,6 +29,8 @@
class BaselineCompareLanding extends Page class BaselineCompareLanding extends Page
{ {
use ResolvesPanelTenantContext;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
@ -94,7 +97,7 @@ public static function canAccess(): bool
return false; return false;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return false; return false;
@ -112,7 +115,7 @@ public function mount(): void
public function refreshStats(): void public function refreshStats(): void
{ {
$stats = BaselineCompareStats::forTenant(Tenant::current()); $stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
$this->state = $stats->state; $this->state = $stats->state;
$this->message = $stats->message; $this->message = $stats->message;
@ -292,10 +295,10 @@ private function compareNowAction(): Action
return; return;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make()->title('No tenant context')->danger()->send(); Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
return; return;
} }
@ -304,9 +307,22 @@ private function compareNowAction(): Action
$result = $service->startCompare($tenant, $user); $result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) { 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() Notification::make()
->title('Cannot start comparison') ->title('Cannot start comparison')
->body('Reason: '.($result['reason_code'] ?? 'unknown')) ->body($message)
->danger() ->danger()
->send(); ->send();
@ -340,7 +356,7 @@ private function compareNowAction(): Action
public function getFindingsUrl(): ?string public function getFindingsUrl(): ?string
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;
@ -355,7 +371,7 @@ public function getRunUrl(): ?string
return null; return null;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;

View File

@ -7,6 +7,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\UserTenantPreference; 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 App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -52,10 +56,10 @@ public function getTenants(): Collection
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
if ($tenants instanceof Collection) { 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 public function selectTenant(int $tenantId): void
@ -66,10 +70,35 @@ public function selectTenant(int $tenantId): void
abort(403); abort(403);
} }
$tenant = Tenant::query() $workspaceContext = app(WorkspaceContext::class);
->where('status', 'active') $workspaceId = $workspaceContext->currentWorkspaceId(request());
->whereKey($tenantId) $tenant = null;
->first();
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) { if (! $tenant instanceof Tenant) {
abort(404); abort(404);
@ -79,13 +108,32 @@ public function selectTenant(int $tenantId): void
abort(404); 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); $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)); $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 private function persistLastTenant(User $user, Tenant $tenant): void
{ {
if (Schema::hasColumn('users', 'last_tenant_id')) { if (Schema::hasColumn('users', 'last_tenant_id')) {

View File

@ -5,6 +5,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -20,6 +21,7 @@
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Support\Enums\FontFamily; use Filament\Support\Enums\FontFamily;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
@ -36,6 +38,7 @@
class InventoryCoverage extends Page implements HasTable class InventoryCoverage extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
use ResolvesPanelTenantContext;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
@ -49,9 +52,18 @@ class InventoryCoverage extends Page implements HasTable
protected string $view = 'filament.pages.inventory-coverage'; 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 public static function canAccess(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {

View File

@ -13,6 +13,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
@ -82,6 +83,9 @@ public function mount(): void
{ {
$this->authorizePageAccess(); $this->authorizePageAccess();
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null; $this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
if ($this->selectedAuditLogId !== null) { if ($this->selectedAuditLogId !== null) {
@ -197,7 +201,7 @@ public function table(Table $table): Table
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No audit events match this view') ->emptyStateHeading('No audit events match this view')
->emptyStateDescription('Clear the current search or filters to return to the workspace audit history.') ->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.')
->emptyStateIcon('heroicon-o-funnel') ->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters') Action::make('clear_filters')

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

@ -8,6 +8,7 @@
use App\Filament\Widgets\Operations\OperationsKpiHeader; use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -51,6 +52,13 @@ class Operations extends Page implements HasForms, HasTable
public function mount(): void public function mount(): void
{ {
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['type', 'initiator_name'],
request(),
);
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
} }
@ -131,8 +139,11 @@ public function table(Table $table): Table
return OperationRunResource::table($table) return OperationRunResource::table($table)
->query(function (): Builder { ->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
$query = OperationRun::query() $query = OperationRun::query()
->with('user') ->with('user')
@ -146,8 +157,8 @@ public function table(Table $table): Table
fn (Builder $query): Builder => $query->whereRaw('1 = 0'), fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
) )
->when( ->when(
$activeTenant instanceof Tenant, is_numeric($tenantFilter),
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()), fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
); );
return $this->applyActiveTab($query); return $this->applyActiveTab($query);
@ -161,6 +172,9 @@ private function applyActiveTab(Builder $query): Builder
OperationRunStatus::Queued->value, OperationRunStatus::Queued->value,
OperationRunStatus::Running->value, OperationRunStatus::Running->value,
]), ]),
'blocked' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Blocked->value),
'succeeded' => $query 'succeeded' => $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value), ->where('outcome', OperationRunOutcome::Succeeded->value),

View File

@ -11,6 +11,7 @@
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService; use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
@ -18,7 +19,11 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -56,6 +61,8 @@ protected function getHeaderActions(): array
{ {
$operateHubShell = app(OperateHubShell::class); $operateHubShell = app(OperateHubShell::class);
$navigationContext = $this->navigationContext(); $navigationContext = $this->navigationContext();
$activeTenant = $operateHubShell->activeEntitledTenant(request());
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
$actions = [ $actions = [
Action::make('operate_hub_scope_run_detail') Action::make('operate_hub_scope_run_detail')
@ -64,14 +71,12 @@ protected function getHeaderActions(): array
->disabled(), ->disabled(),
]; ];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_run_detail') $actions[] = Action::make('operate_hub_back_to_origin_run_detail')
->label($navigationContext->backLinkLabel) ->label($navigationContext->backLinkLabel)
->color('gray') ->color('gray')
->url($navigationContext->backLinkUrl); ->url($navigationContext->backLinkUrl);
} elseif ($activeTenant instanceof Tenant) { } elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail') $actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name) ->label('← Back to '.$activeTenant->name)
->color('gray') ->color('gray')
@ -102,14 +107,7 @@ protected function getHeaderActions(): array
return $actions; return $actions;
} }
$user = auth()->user(); $related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
$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);
$relatedActions = []; $relatedActions = [];
@ -163,6 +161,86 @@ public function redactionIntegrityNote(): ?string
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null; 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;
}
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $reasonEnvelope?->toBodyLines() ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
];
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', $lines),
];
}
/**
* @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 public function pollInterval(): ?string
{ {
if (! isset($this->run)) { if (! isset($this->run)) {
@ -313,4 +391,30 @@ private function canResumeCapture(): bool
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); && $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;
}
} }

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)->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)->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;
}
}

View File

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

View File

@ -12,6 +12,8 @@
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page; use Filament\Pages\Page;
use Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantRequiredPermissions extends Page class TenantRequiredPermissions extends Page
{ {
@ -41,7 +43,8 @@ class TenantRequiredPermissions extends Page
*/ */
public array $viewModel = []; public array $viewModel = [];
public ?Tenant $scopedTenant = null; #[Locked]
public ?int $scopedTenantId = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
@ -50,7 +53,7 @@ public static function canAccess(): bool
public function currentTenant(): ?Tenant public function currentTenant(): ?Tenant
{ {
return $this->scopedTenant; return $this->trustedScopedTenant();
} }
public function mount(): void public function mount(): void
@ -61,7 +64,9 @@ public function mount(): void
abort(404); abort(404);
} }
$this->scopedTenant = $tenant; $this->scopedTenantId = (int) $tenant->getKey();
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
$queryFeatures = request()->query('features', $this->features); $queryFeatures = request()->query('features', $this->features);
@ -141,7 +146,7 @@ public function resetFilters(): void
private function refreshViewModel(): void private function refreshViewModel(): void
{ {
$tenant = $this->scopedTenant; $tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$this->viewModel = []; $this->viewModel = [];
@ -170,7 +175,7 @@ private function refreshViewModel(): void
public function reRunVerificationUrl(): string public function reRunVerificationUrl(): string
{ {
$tenant = $this->scopedTenant; $tenant = $this->trustedScopedTenant();
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]); return TenantResource::getUrl('view', ['record' => $tenant]);
@ -181,7 +186,7 @@ public function reRunVerificationUrl(): string
public function manageProviderConnectionUrl(): ?string public function manageProviderConnectionUrl(): ?string
{ {
$tenant = $this->scopedTenant; $tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;
@ -232,4 +237,47 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
return $user->canAccessTenant($tenant); 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;
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,14 @@
namespace App\Filament\Pages\Workspaces; namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\TenantResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; 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 Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -54,11 +58,25 @@ public function getTenants(): Collection
return Tenant::query()->whereRaw('1 = 0')->get(); 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('workspace_id', $this->workspace->getKey())
->where('status', 'active')
->orderBy('name') ->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 public function goToChooseTenant(): void
@ -75,7 +93,7 @@ public function openTenant(int $tenantId): void
} }
$tenant = Tenant::query() $tenant = Tenant::query()
->where('status', 'active') ->withTrashed()
->where('workspace_id', $this->workspace->getKey()) ->where('workspace_id', $this->workspace->getKey())
->whereKey($tenantId) ->whereKey($tenantId)
->first(); ->first();
@ -88,6 +106,6 @@ public function openTenant(int $tenantId): void
abort(404); abort(404);
} }
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); $this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
} }
} }

View File

@ -5,7 +5,7 @@
namespace App\Filament\Resources\AlertDeliveryResource\Pages; namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDeliveryResource;
use App\Models\Tenant; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -15,21 +15,7 @@ class ListAlertDeliveries extends ListRecords
public function mount(): void public function mount(): void
{ {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
if ($activeTenant instanceof Tenant) {
$filtersSessionKey = $this->getTableFiltersSessionKey();
$persistedFilters = session()->get($filtersSessionKey, []);
if (! is_array($persistedFilters)) {
$persistedFilters = [];
}
if (! is_string(data_get($persistedFilters, 'tenant_id.value'))) {
data_set($persistedFilters, 'tenant_id.value', (string) $activeTenant->getKey());
session()->put($filtersSessionKey, $persistedFilters);
}
}
parent::mount(); parent::mount();
} }

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Exceptions\InvalidPolicyTypeException; use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupScheduleResource\Pages; use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Jobs\RunBackupScheduleJob; use App\Jobs\RunBackupScheduleJob;
@ -38,6 +40,7 @@
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -62,6 +65,9 @@
class BackupScheduleResource extends Resource class BackupScheduleResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSchedule::class; protected static ?string $model = BackupSchedule::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -70,9 +76,18 @@ class BackupScheduleResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; 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 canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -88,7 +103,7 @@ public static function canViewAny(): bool
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -112,7 +127,7 @@ public static function canView(Model $record): bool
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -128,7 +143,7 @@ public static function canCreate(): bool
public static function canEdit(Model $record): bool public static function canEdit(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -144,7 +159,7 @@ public static function canEdit(Model $record): bool
public static function canDelete(Model $record): bool public static function canDelete(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -160,7 +175,7 @@ public static function canDelete(Model $record): bool
public static function canDeleteAny(): bool public static function canDeleteAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -421,7 +436,7 @@ public static function table(Table $table): Table
->color('success') ->color('success')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, HasTable $livewire): void { ->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -492,7 +507,7 @@ public static function table(Table $table): Table
->color('warning') ->color('warning')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, HasTable $livewire): void { ->action(function (BackupSchedule $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -568,6 +583,8 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('delete', $record); Gate::authorize('delete', $record);
if ($record->trashed()) { if ($record->trashed()) {
@ -609,6 +626,8 @@ public static function table(Table $table): Table
->color('success') ->color('success')
->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('restore', $record); Gate::authorize('restore', $record);
if (! $record->trashed()) { if (! $record->trashed()) {
@ -649,6 +668,8 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('forceDelete', $record); Gate::authorize('forceDelete', $record);
if (! $record->trashed()) { if (! $record->trashed()) {
@ -706,7 +727,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -721,7 +742,7 @@ public static function table(Table $table): Table
return; return;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$userId = auth()->id(); $userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null; $user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */ /** @var OperationRunService $operationRunService */
@ -803,7 +824,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('warning')
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
Notification::make() Notification::make()
@ -818,7 +839,7 @@ public static function table(Table $table): Table
return; return;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$userId = auth()->id(); $userId = auth()->id();
$user = $userId ? User::query()->find($userId) : null; $user = $userId ? User::query()->find($userId) : null;
/** @var OperationRunService $operationRunService */ /** @var OperationRunService $operationRunService */
@ -906,17 +927,32 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::currentOrFail()->getKey(); return static::getTenantOwnedEloquentQuery()
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
->orderByDesc('is_enabled') ->orderByDesc('is_enabled')
->orderBy('next_run_at'); ->orderBy('next_run_at');
} }
public static function getRecordRouteBindingEloquentQuery(): Builder public static function getRecordRouteBindingEloquentQuery(): Builder
{ {
return static::getEloquentQuery()->withTrashed(); return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed())
->orderByDesc('is_enabled')
->orderBy('next_run_at');
}
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
}
protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof BackupSchedule) {
abort(404);
}
return $resolvedRecord;
} }
public static function getRelations(): array public static function getRelations(): array
@ -1027,7 +1063,7 @@ public static function ensurePolicyTypes(array $data): array
public static function assignTenant(array $data): array public static function assignTenant(array $data): array
{ {
$data['tenant_id'] = Tenant::currentOrFail()->getKey(); $data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return $data; return $data;
} }

View File

@ -5,7 +5,6 @@
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class EditBackupSchedule extends EditRecord class EditBackupSchedule extends EditRecord
{ {
@ -13,15 +12,7 @@ class EditBackupSchedule extends EditRecord
protected function resolveRecord(int|string $key): Model protected function resolveRecord(int|string $key): Model
{ {
$record = BackupScheduleResource::getEloquentQuery() return BackupScheduleResource::resolveScopedRecordOrFail($key);
->withTrashed()
->find($key);
if ($record === null) {
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
}
return $record;
} }
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeSave(array $data): array

View File

@ -3,12 +3,38 @@
namespace App\Filament\Resources\BackupScheduleResource\Pages; namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ListBackupSchedules extends ListRecords class ListBackupSchedules extends ListRecords
{ {
protected static string $resource = BackupScheduleResource::class; 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 protected function getHeaderActions(): array
{ {
return [ return [
@ -28,4 +54,14 @@ private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; return $this->getTableRecords()->count() > 0;
} }
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: [],
request: request(),
tenantFilterName: null,
);
}
} }

View File

@ -12,6 +12,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Closure;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
@ -24,6 +25,19 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
protected static ?string $title = 'Executions'; 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 public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
@ -48,7 +62,7 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Type') ->label('Type')
->formatStateUsing([OperationCatalog::class, 'label']), ->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
@ -87,6 +101,7 @@ public function table(Table $table): Table
->label('View') ->label('View')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->url(function (OperationRun $record): string { ->url(function (OperationRun $record): string {
$record = $this->resolveOwnerScopedOperationRun($record);
$tenant = Tenant::currentOrFail(); $tenant = Tenant::currentOrFail();
return OperationRunLinks::view($record, $tenant); return OperationRunLinks::view($record, $tenant);
@ -97,4 +112,32 @@ public function table(Table $table): Table
->emptyStateHeading('No schedule runs yet') ->emptyStateHeading('No schedule runs yet')
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.'); ->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

@ -2,6 +2,8 @@
namespace App\Filament\Resources; 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\Pages;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\BulkBackupSetDeleteJob; use App\Jobs\BulkBackupSetDeleteJob;
@ -55,6 +57,9 @@
class BackupSetResource extends Resource class BackupSetResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSet::class; protected static ?string $model = BackupSet::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -63,6 +68,15 @@ class BackupSetResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; 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 public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
@ -76,7 +90,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -92,7 +106,7 @@ public static function canViewAny(): bool
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -108,13 +122,12 @@ public static function canCreate(): bool
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenant = Filament::getTenant(); return static::getTenantOwnedEloquentQuery();
}
if (! $tenant instanceof Tenant) { public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
return parent::getEloquentQuery()->whereRaw('1 = 0'); {
} return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@ -182,7 +195,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed()) ->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
$record->restore(); $record->restore();
$record->items()->withTrashed()->restore(); $record->items()->withTrashed()->restore();
@ -215,7 +228,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (BackupSet $record): bool => ! $record->trashed()) ->visible(fn (BackupSet $record): bool => ! $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
$record->delete(); $record->delete();
@ -247,7 +260,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (BackupSet $record): bool => $record->trashed()) ->visible(fn (BackupSet $record): bool => $record->trashed())
->action(function (BackupSet $record, AuditLogger $auditLogger) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
if ($record->restoreRuns()->withTrashed()->exists()) { if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make() Notification::make()
@ -317,7 +330,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -387,7 +400,7 @@ public static function table(Table $table): Table
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?") ->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.') ->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -472,7 +485,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -622,7 +635,7 @@ private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextE
public static function createBackupSet(array $data): BackupSet public static function createBackupSet(array $data): BackupSet
{ {
/** @var Tenant $tenant */ /** @var Tenant $tenant */
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
/** @var BackupService $service */ /** @var BackupService $service */
$service = app(BackupService::class); $service = app(BackupService::class);

View File

@ -3,12 +3,24 @@
namespace App\Filament\Resources\BackupSetResource\Pages; namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListBackupSets extends ListRecords class ListBackupSets extends ListRecords
{ {
protected static string $resource = BackupSetResource::class; 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 private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; return $this->getTableRecords()->count() > 0;

View File

@ -4,6 +4,7 @@
namespace App\Filament\Resources\BackupSetResource\Pages; namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
@ -16,11 +17,19 @@
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewBackupSet extends ViewRecord class ViewBackupSet extends ViewRecord
{ {
use ResolvesPanelTenantContext;
protected static string $resource = BackupSetResource::class; protected static string $resource = BackupSetResource::class;
protected function resolveRecord(int|string $key): Model
{
return BackupSetResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$actions = [ $actions = [
@ -79,7 +88,7 @@ private function restoreAction(): Action
->success() ->success()
->send(); ->send();
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: Tenant::current()), navigate: true); $this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
}); });
UiEnforcement::forAction($action) UiEnforcement::forAction($action)
@ -120,7 +129,7 @@ private function archiveAction(): Action
->success() ->success()
->send(); ->send();
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true); $this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
}); });
UiEnforcement::forAction($action) UiEnforcement::forAction($action)
@ -172,7 +181,7 @@ private function forceDeleteAction(): Action
->success() ->success()
->send(); ->send();
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true); $this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
}); });
UiEnforcement::forAction($action) UiEnforcement::forAction($action)

View File

@ -43,6 +43,27 @@ public function closeAddPoliciesModal(): void
$this->unmountAction(); $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 public function table(Table $table): Table
{ {
$refreshTable = Actions\Action::make('refreshTable') $refreshTable = Actions\Action::make('refreshTable')
@ -77,7 +98,7 @@ public function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->requiresConfirmation() ->requiresConfirmation()
->action(function (BackupItem $record): void { ->action(function (mixed $record): void {
$backupSet = $this->getOwnerRecord(); $backupSet = $this->getOwnerRecord();
$user = auth()->user(); $user = auth()->user();
@ -94,7 +115,7 @@ public function table(Table $table): Table
abort(404); abort(404);
} }
$backupItemIds = [(int) $record->getKey()]; $backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -173,14 +194,7 @@ public function table(Table $table): Table
abort(404); abort(404);
} }
$backupItemIds = $records $backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) { if ($backupItemIds === []) {
return; return;
@ -434,4 +448,68 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
return $query->whereIn('policy_type', $types); 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

@ -6,19 +6,28 @@
use App\Filament\Resources\BaselineProfileResource\Pages; use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement; 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\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -288,15 +297,32 @@ public static function infolist(Schema $schema): Schema
->placeholder('None'), ->placeholder('None'),
]) ])
->columnSpanFull(), ->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') Section::make('Metadata')
->schema([ ->schema([
TextEntry::make('createdByUser.name') TextEntry::make('createdByUser.name')
->label('Created by') ->label('Created by')
->placeholder('—'), ->placeholder('—'),
TextEntry::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot yet'),
TextEntry::make('created_at') TextEntry::make('created_at')
->dateTime(), ->dateTime(),
TextEntry::make('updated_at') TextEntry::make('updated_at')
@ -355,10 +381,27 @@ public static function table(Table $table): Table
TextColumn::make('tenant_assignments_count') TextColumn::make('tenant_assignments_count')
->label('Assigned tenants') ->label('Assigned tenants')
->counts('tenantAssignments'), ->counts('tenantAssignments'),
TextColumn::make('activeSnapshot.captured_at') TextColumn::make('current_snapshot_truth')
->label('Last snapshot') ->label('Current snapshot')
->dateTime() ->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
->placeholder('No snapshot'), ->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') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
@ -545,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): Action
return $action; 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

@ -183,7 +183,7 @@ private function compareNowAction(): Action
$modalDescription = $captureMode === BaselineCaptureMode::FullContent $modalDescription = $captureMode === BaselineCaptureMode::FullContent
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.' ? '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 active baseline snapshot.'; : 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
return Action::make('compareNow') return Action::make('compareNow')
->label($label) ->label($label)
@ -198,7 +198,7 @@ private function compareNowAction(): Action
->required() ->required()
->searchable(), ->searchable(),
]) ])
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === []) ->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
->action(function (array $data): void { ->action(function (array $data): void {
$user = auth()->user(); $user = auth()->user();
@ -256,7 +256,11 @@ private function compareNowAction(): Action
$message = match ($reasonCode) { $message = match ($reasonCode) {
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.', 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_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.', 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), default => 'Reason: '.str_replace('.', ' ', $reasonCode),
}; };
@ -395,4 +399,12 @@ private function hasManageCapability(): bool
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
} }
private function profileHasConsumableSnapshot(): bool
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return $profile->resolveCurrentConsumableSnapshot() !== null;
}
} }

View File

@ -9,7 +9,12 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Auth\Capabilities; 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\Filament\FilterPresets;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedContextEntry;
@ -18,6 +23,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; 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 App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -165,15 +172,39 @@ public static function table(Table $table): Table
->label('Captured') ->label('Captured')
->since() ->since()
->sortable(), ->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)->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') TextColumn::make('fidelity_summary')
->label('Fidelity') ->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)) ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(), ->wrap(),
TextColumn::make('snapshot_state') TextColumn::make('artifact_next_step')
->label('State') ->label('Next step')
->badge() ->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) ->wrap(),
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
]) ])
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record) ->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record]) ? static::getUrl('view', ['record' => $record])
@ -183,10 +214,10 @@ public static function table(Table $table): Table
->label('Baseline') ->label('Baseline')
->options(static::baselineProfileOptions()) ->options(static::baselineProfileOptions())
->searchable(), ->searchable(),
SelectFilter::make('snapshot_state') SelectFilter::make('lifecycle_state')
->label('State') ->label('Lifecycle')
->options(static::snapshotStateOptions()) ->options(static::lifecycleOptions())
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)), ->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'), FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
]) ])
->actions([ ->actions([
@ -247,12 +278,9 @@ private static function baselineProfileOptions(): array
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
private static function snapshotStateOptions(): array private static function lifecycleOptions(): array
{ {
return [ return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
'complete' => 'Complete',
'with_gaps' => 'Captured with gaps',
];
} }
public static function resolveWorkspace(): ?Workspace public static function resolveWorkspace(): ?Workspace
@ -290,7 +318,13 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
{ {
$counts = self::fidelityCounts($snapshot); $counts = self::fidelityCounts($snapshot);
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0)); 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 private static function gapsCount(BaselineSnapshot $snapshot): int
@ -298,6 +332,17 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
$summary = self::summary($snapshot); $summary = self::summary($snapshot);
$gaps = $summary['gaps'] ?? null; $gaps = $summary['gaps'] ?? null;
$gaps = is_array($gaps) ? $gaps : []; $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; $count = $gaps['count'] ?? 0;
@ -309,32 +354,86 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
return self::gapsCount($snapshot) > 0; return self::gapsCount($snapshot) > 0;
} }
private static function stateLabel(BaselineSnapshot $snapshot): string private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{ {
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete'; return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
} }
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
{ {
if (! is_string($value) || trim($value) === '') { if (! is_string($value) || trim($value) === '') {
return $query; return $query;
} }
$gapCountExpression = self::gapCountExpression($query); return $query->where('lifecycle_state', trim($value));
return match ($value) {
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
default => $query,
};
} }
private static function gapCountExpression(Builder $query): string private static function gapCountExpression(Builder $query): string
{ {
return match ($query->getConnection()->getDriverName()) { return match ($query->getConnection()->getDriverName()) {
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)", 'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)", 'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)", default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 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

@ -35,6 +35,8 @@ public function mount(int|string $record): void
$snapshot = $this->getRecord(); $snapshot = $this->getRecord();
if ($snapshot instanceof BaselineSnapshot) { if ($snapshot instanceof BaselineSnapshot) {
$snapshot->loadMissing(['baselineProfile', 'items']);
$relatedContext = app(RelatedNavigationResolver::class) $relatedContext = app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot); ->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);

View File

@ -2,6 +2,9 @@
namespace App\Filament\Resources; 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\Filament\Resources\EntraGroupResource\Pages;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\Tenant; use App\Models\Tenant;
@ -17,6 +20,7 @@
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData; use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use BackedEnum; use BackedEnum;
use Filament\Facades\Filament;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@ -24,21 +28,39 @@
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use UnitEnum; use UnitEnum;
class EntraGroupResource extends Resource class EntraGroupResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false; protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroup::class; 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|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory'; protected static string|UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $navigationLabel = 'Groups'; 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 public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
@ -75,13 +97,8 @@ public static function table(Table $table): Table
->persistFiltersInSession() ->persistFiltersInSession()
->persistSearchInSession() ->persistSearchInSession()
->persistSortInSession() ->persistSortInSession()
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record) ->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record]) ? static::scopedUrl('view', ['record' => $record], static::panelTenantContext())
: null) : null)
->columns([ ->columns([
Tables\Columns\TextColumn::make('display_name') Tables\Columns\TextColumn::make('display_name')
@ -176,7 +193,22 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder 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 public static function getPages(): array
@ -187,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 private static function groupType(EntraGroup $record): string
{ {
$groupTypes = $record->group_types; $groupTypes = $record->group_types;

View File

@ -9,25 +9,47 @@
use App\Services\Directory\EntraGroupSelection; use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords class ListEntraGroups extends ListRecords
{ {
protected static string $resource = EntraGroupResource::class; 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 protected function getHeaderActions(): array
{ {
$tenant = EntraGroupResource::panelTenantContext();
return [ return [
Action::make('view_operations') Action::make('view_operations')
->label('Operations') ->label('Operations')
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->url(fn (): string => OperationRunLinks::index(Tenant::current())) ->url(fn (): string => OperationRunLinks::index($tenant))
->visible(fn (): bool => (bool) Tenant::current()), ->visible(fn (): bool => $tenant instanceof Tenant),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('sync_groups') Action::make('sync_groups')
->label('Sync Groups') ->label('Sync Groups')
@ -35,7 +57,7 @@ protected function getHeaderActions(): array
->color('primary') ->color('primary')
->action(function (): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
$tenant = Tenant::current(); $tenant = EntraGroupResource::panelTenantContext();
if (! $user instanceof User || ! $tenant instanceof Tenant) { if (! $user instanceof User || ! $tenant instanceof Tenant) {
return; return;

View File

@ -3,9 +3,49 @@
namespace App\Filament\Resources\EntraGroupResource\Pages; namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource; 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 Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewEntraGroup extends ViewRecord class ViewEntraGroup extends ViewRecord
{ {
protected static string $resource = EntraGroupResource::class; 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

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

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Section;
class ListEvidenceSnapshots extends ListRecords
{
protected static string $resource = EvidenceSnapshotResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('create_snapshot')
->label('Create snapshot')
->icon('heroicon-o-plus')
->action(fn (array $data): mixed => EvidenceSnapshotResource::executeGeneration($data))
->form([
Section::make('Snapshot options')
->schema([
Toggle::make('allow_stale')
->label('Allow stale dimensions')
->default(false),
]),
]),
)
->requireCapability(Capabilities::EVIDENCE_MANAGE)
->apply(),
];
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewEvidenceSnapshot extends ViewRecord
{
protected static string $resource = EvidenceSnapshotResource::class;
protected function resolveRecord(int|string $key): Model
{
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_run')
->label('View run')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
Actions\Action::make('view_review_pack')
->label('View review pack')
->icon('heroicon-o-document-text')
->color('gray')
->url(function (): ?string {
$pack = $this->latestReviewPack();
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
return null;
}
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
})
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
UiEnforcement::forAction(
Actions\Action::make('refresh_snapshot')
->label('Refresh evidence')
->icon('heroicon-o-arrow-path')
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
Notification::make()->success()->title('Refresh evidence queued')->send();
}),
)
->requireCapability(Capabilities::EVIDENCE_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('expire_snapshot')
->label('Expire snapshot')
->icon('heroicon-o-x-circle')
->color('danger')
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(EvidenceSnapshotService::class)->expire($this->record, $user);
$this->refreshFormData(['status', 'expires_at']);
Notification::make()->success()->title('Snapshot expired')->send();
}),
)
->requireCapability(Capabilities::EVIDENCE_MANAGE)
->apply(),
];
}
private function latestReviewPack(): ?ReviewPack
{
return $this->record->reviewPacks()
->latest('created_at')
->first();
}
}

View File

@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
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\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 BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry;
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;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use UnitEnum;
class FindingExceptionResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = FindingException::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Risk exceptions';
protected static ?int $navigationSort = 60;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
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::FINDING_EXCEPTION_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::FINDING_EXCEPTION_VIEW, $tenant)) {
return false;
}
return ! $record instanceof FindingException
|| ((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, 'List header links back to findings where exception requests originate.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->with(static::relationshipsForView());
}
public static function resolveScopedRecordOrFail(int|string|null $record): Model
{
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make('Exception')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
TextEntry::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)),
TextEntry::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
->columnSpanFull()
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => static::findingSummary($record)),
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
])
->columns(2),
Section::make('Decision history')
->schema([
RepeatableEntry::make('decisions')
->hiddenLabel()
->schema([
TextEntry::make('decision_type')->label('Decision'),
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
])
->columns(3),
]),
Section::make('Evidence references')
->schema([
RepeatableEntry::make('evidenceReferences')
->hiddenLabel()
->schema([
TextEntry::make('label')->label('Label'),
TextEntry::make('source_type')->label('Source'),
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
TextEntry::make('summary_payload')
->label('Summary')
->state(function (FindingExceptionEvidenceReference $record): ?string {
if ($record->summary_payload === [] || $record->summary_payload === null) {
return null;
}
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
})
->placeholder('—')
->columnSpanFull(),
])
->columns(2),
])
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('requested_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\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))
->sortable(),
Tables\Columns\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))
->sortable(),
Tables\Columns\TextColumn::make('finding_summary')
->label('Finding')
->state(fn (FindingException $record): string => static::findingSummary($record))
->searchable(),
Tables\Columns\TextColumn::make('governance_warning')
->label('Governance warning')
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
->wrap(),
Tables\Columns\TextColumn::make('requester.name')
->label('Requested by')
->placeholder('—'),
Tables\Columns\TextColumn::make('owner.name')
->label('Owner')
->placeholder('—'),
Tables\Columns\TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->placeholder('—')
->sortable(),
Tables\Columns\TextColumn::make('requested_at')
->label('Requested')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('status')
->options(FilterOptionCatalog::findingExceptionStatuses()),
SelectFilter::make('current_validity_state')
->label('Validity')
->options(FilterOptionCatalog::findingExceptionValidityStates()),
])
->actions([
Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
$user = auth()->user();
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
abort(404);
}
try {
$service->renew($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->send();
}),
Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(404);
}
try {
$service->revoke($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->send();
}),
])
->bulkActions([])
->emptyStateHeading('No exceptions match this view')
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
->emptyStateIcon('heroicon-o-shield-exclamation')
->emptyStateActions([
Action::make('open_findings')
->label('Open findings')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn (): string => FindingResource::getUrl('index')),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListFindingExceptions::route('/'),
'view' => Pages\ViewFindingException::route('/{record}'),
];
}
/**
* @return array<int, string|array<int|string, mixed>>
*/
private static function relationshipsForView(): array
{
return [
'tenant',
'requester',
'owner',
'approver',
'currentDecision',
'decisions.actor',
'evidenceReferences',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
];
}
/**
* @return array<int, string>
*/
private static function tenantMemberOptions(): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return [];
}
return \App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
->orderBy('users.name')
->pluck('users.name', 'users.id')
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
->all();
}
private static function findingSummary(FindingException $record): string
{
$summary = $record->finding?->resolvedSubjectDisplayName();
if (is_string($summary) && trim($summary) !== '') {
return trim($summary);
}
return 'Finding #'.$record->finding_id;
}
private static function canManageRecord(FindingException $record): bool
{
$user = auth()->user();
return $user instanceof User
&& $record->tenant instanceof Tenant
&& $user->canAccessTenant($record->tenant)
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
}
private static function governanceWarning(FindingException $record): ?string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if (! $finding instanceof \App\Models\Finding) {
return null;
}
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
}
private static function governanceWarningColor(FindingException $record): string
{
$finding = $record->relationLoaded('finding')
? $record->finding
: $record->finding()->withSubjectDisplayName()->first();
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
return 'warning';
}
return 'danger';
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
class ListFindingExceptions extends ListRecords
{
protected static string $resource = FindingExceptionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('open_findings')
->label('Open findings')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(FindingResource::getUrl('index')),
];
}
}

View File

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class ViewFindingException extends ViewRecord
{
protected static string $resource = FindingExceptionResource::class;
protected function resolveRecord(int|string $key): Model
{
return FindingExceptionResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return [
Action::make('open_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(function (): ?string {
$record = $this->getRecord();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}),
Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
->fillForm(fn (): array => [
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
])
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->getRecord();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
try {
$service->renew($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->send();
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
}),
Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->getRecord();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
try {
$service->revoke($record, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->send();
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
}),
];
}
/**
* @return array<int, string>
*/
private function tenantMemberOptions(): array
{
$record = $this->getRecord();
if (! $record instanceof FindingException) {
return [];
}
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return [];
}
return \App\Models\TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
->orderBy('users.name')
->pluck('users.name', 'users.id')
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
->all();
}
private function canManageRecord(): bool
{
$record = $this->getRecord();
$user = auth()->user();
return $record instanceof FindingException
&& $record->tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($record->tenant)
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
}
}

View File

@ -2,14 +2,18 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource\Pages; use App\Filament\Resources\FindingResource\Pages;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\FindingException;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantMembership; use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder; use App\Services\Drift\DriftFindingDiffBuilder;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
@ -34,6 +38,8 @@
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -55,6 +61,9 @@
class FindingResource extends Resource class FindingResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = Finding::class; protected static ?string $model = Finding::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -65,9 +74,18 @@ class FindingResource extends Resource
protected static ?string $navigationLabel = 'Findings'; protected static ?string $navigationLabel = 'Findings';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -84,7 +102,7 @@ public static function canViewAny(): bool
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -101,7 +119,8 @@ public static function canView(Model $record): bool
} }
if ($record instanceof Finding) { if ($record instanceof Finding) {
return (int) $record->tenant_id === (int) $tenant->getKey(); return (int) $record->tenant_id === (int) $tenant->getKey()
&& (int) $record->workspace_id === (int) $tenant->workspace_id;
} }
return true; return true;
@ -162,17 +181,7 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('subject_display_name') TextEntry::make('subject_display_name')
->label('Subject') ->label('Subject')
->placeholder('—') ->placeholder('—')
->state(function (Finding $record): ?string { ->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
$state = $record->subject_display_name;
if (is_string($state) && trim($state) !== '') {
return $state;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
}),
TextEntry::make('subject_type') TextEntry::make('subject_type')
->label('Subject type') ->label('Subject type')
->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)), ->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)),
@ -219,6 +228,62 @@ public static function infolist(Schema $schema): Schema
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Risk governance')
->schema([
TextEntry::make('finding_governance_status')
->label('Exception status')
->badge()
->state(fn (Finding $record): ?string => $record->findingException?->status)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
->placeholder('—'),
TextEntry::make('finding_governance_validity')
->label('Validity')
->badge()
->state(function (Finding $record): ?string {
if ($record->findingException instanceof FindingException) {
return $record->findingException->current_validity_state;
}
return (string) $record->status === Finding::STATUS_RISK_ACCEPTED
? FindingException::VALIDITY_MISSING_SUPPORT
: null;
})
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
->placeholder('—'),
TextEntry::make('finding_governance_warning')
->label('Governance warning')
->state(fn (Finding $record): ?string => static::governanceWarning($record))
->color(fn (Finding $record): string => static::governanceWarningColor($record))
->columnSpanFull()
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
TextEntry::make('finding_governance_owner')
->label('Exception owner')
->state(fn (Finding $record): ?string => $record->findingException?->owner?->name)
->placeholder('—'),
TextEntry::make('finding_governance_approver')
->label('Approver')
->state(fn (Finding $record): ?string => $record->findingException?->approver?->name)
->placeholder('—'),
TextEntry::make('finding_governance_review_due')
->label('Review due')
->state(fn (Finding $record): mixed => $record->findingException?->review_due_at)
->dateTime()
->placeholder('—'),
TextEntry::make('finding_governance_expires')
->label('Expires')
->state(fn (Finding $record): mixed => $record->findingException?->expires_at)
->dateTime()
->placeholder('—'),
])
->columns(2)
->visible(fn (Finding $record): bool => $record->findingException instanceof FindingException || (string) $record->status === Finding::STATUS_RISK_ACCEPTED),
Section::make('Evidence') Section::make('Evidence')
->schema([ ->schema([
TextEntry::make('redaction_integrity_note') TextEntry::make('redaction_integrity_note')
@ -302,7 +367,7 @@ public static function infolist(Schema $schema): Schema
->label('') ->label('')
->view('filament.infolists.entries.normalized-diff') ->view('filament.infolists.entries.normalized-diff')
->state(function (Finding $record): array { ->state(function (Finding $record): array {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return static::unavailableDiffState('No tenant context'); return static::unavailableDiffState('No tenant context');
} }
@ -336,7 +401,7 @@ public static function infolist(Schema $schema): Schema
->label('') ->label('')
->view('filament.infolists.entries.scope-tags-diff') ->view('filament.infolists.entries.scope-tags-diff')
->state(function (Finding $record): array { ->state(function (Finding $record): array {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return static::unavailableDiffState('No tenant context'); return static::unavailableDiffState('No tenant context');
} }
@ -356,7 +421,7 @@ public static function infolist(Schema $schema): Schema
->label('') ->label('')
->view('filament.infolists.entries.assignments-diff') ->view('filament.infolists.entries.assignments-diff')
->state(function (Finding $record): array { ->state(function (Finding $record): array {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return static::unavailableDiffState('No tenant context'); return static::unavailableDiffState('No tenant context');
} }
@ -591,16 +656,7 @@ public static function table(Table $table): Table
->label('Subject') ->label('Subject')
->placeholder('—') ->placeholder('—')
->searchable() ->searchable()
->formatStateUsing(function (?string $state, Finding $record): ?string { ->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
if (is_string($state) && trim($state) !== '') {
return $state;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
})
->description(fn (Finding $record): ?string => static::driftContextDescription($record)), ->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
Tables\Columns\TextColumn::make('subject_type') Tables\Columns\TextColumn::make('subject_type')
->label('Subject type') ->label('Subject type')
@ -725,7 +781,7 @@ public static function table(Table $table): Table
->color('gray') ->color('gray')
->requiresConfirmation() ->requiresConfirmation()
->action(function (Collection $records, FindingWorkflowService $workflow): void { ->action(function (Collection $records, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -760,6 +816,7 @@ public static function table(Table $table): Table
} }
try { try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->triage($record, $tenant, $user); $workflow->triage($record, $tenant, $user);
$triagedCount++; $triagedCount++;
} catch (Throwable) { } catch (Throwable) {
@ -806,7 +863,7 @@ public static function table(Table $table): Table
->searchable(), ->searchable(),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -840,6 +897,7 @@ public static function table(Table $table): Table
} }
try { try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId); $workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
$assignedCount++; $assignedCount++;
} catch (Throwable) { } catch (Throwable) {
@ -881,7 +939,7 @@ public static function table(Table $table): Table
->maxLength(255), ->maxLength(255),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -914,6 +972,7 @@ public static function table(Table $table): Table
} }
try { try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->resolve($record, $tenant, $user, $reason); $workflow->resolve($record, $tenant, $user, $reason);
$resolvedCount++; $resolvedCount++;
} catch (Throwable) { } catch (Throwable) {
@ -955,7 +1014,7 @@ public static function table(Table $table): Table
->maxLength(255), ->maxLength(255),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -988,6 +1047,7 @@ public static function table(Table $table): Table
} }
try { try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->close($record, $tenant, $user, $reason); $workflow->close($record, $tenant, $user, $reason);
$closedCount++; $closedCount++;
} catch (Throwable) { } catch (Throwable) {
@ -1015,79 +1075,6 @@ public static function table(Table $table): Table
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(), ->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('risk_accept_selected')
->label('Risk accept selected')
->icon('heroicon-o-shield-check')
->color('warning')
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
->label('Risk acceptance reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
$reason = (string) ($data['closed_reason'] ?? '');
$acceptedCount = 0;
$skippedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
if (! $record instanceof Finding) {
$skippedCount++;
continue;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
$skippedCount++;
continue;
}
if (! $record->hasOpenStatus()) {
$skippedCount++;
continue;
}
try {
$workflow->riskAccept($record, $tenant, $user, $reason);
$acceptedCount++;
} catch (Throwable) {
$failedCount++;
}
}
$body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.';
if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}.";
}
if ($failedCount > 0) {
$body .= " Failed {$failedCount}.";
}
Notification::make()
->title('Bulk risk accept completed')
->body($body)
->status($failedCount > 0 ? 'warning' : 'success')
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
])->label('More'), ])->label('More'),
]) ])
->emptyStateHeading('No findings match this view') ->emptyStateHeading('No findings match this view')
@ -1097,18 +1084,19 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::current()?->getKey(); return static::getTenantOwnedEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
->withSubjectDisplayName();
}
return parent::getEloquentQuery() public static function resolveScopedRecordOrFail(int|string $key): Model
->with(['assigneeUser', 'ownerUser', 'closedByUser']) {
->addSelect([ return static::resolveTenantOwnedRecordOrFail(
'subject_display_name' => InventoryItem::query() $key,
->select('display_name') parent::getEloquentQuery()
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id') ->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
->whereColumn('inventory_items.external_id', 'findings.subject_external_id') ->withSubjectDisplayName(),
->limit(1), );
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
} }
/** /**
@ -1184,7 +1172,9 @@ public static function workflowActions(): array
static::assignAction(), static::assignAction(),
static::resolveAction(), static::resolveAction(),
static::closeAction(), static::closeAction(),
static::riskAcceptAction(), static::requestExceptionAction(),
static::renewExceptionAction(),
static::revokeExceptionAction(),
static::reopenAction(), static::reopenAction(),
]; ];
} }
@ -1196,7 +1186,7 @@ public static function triageAction(): Actions\Action
->label('Triage') ->label('Triage')
->icon('heroicon-o-check') ->icon('heroicon-o-check')
->color('gray') ->color('gray')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [ ->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED, Finding::STATUS_ACKNOWLEDGED,
@ -1222,7 +1212,7 @@ public static function startProgressAction(): Actions\Action
->label('Start progress') ->label('Start progress')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('info') ->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [ ->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED, Finding::STATUS_ACKNOWLEDGED,
], true)) ], true))
@ -1247,7 +1237,7 @@ public static function assignAction(): Actions\Action
->label('Assign') ->label('Assign')
->icon('heroicon-o-user-plus') ->icon('heroicon-o-user-plus')
->color('gray') ->color('gray')
->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->fillForm(fn (Finding $record): array => [ ->fillForm(fn (Finding $record): array => [
'assignee_user_id' => $record->assignee_user_id, 'assignee_user_id' => $record->assignee_user_id,
'owner_user_id' => $record->owner_user_id, 'owner_user_id' => $record->owner_user_id,
@ -1291,7 +1281,7 @@ public static function resolveAction(): Actions\Action
->label('Resolve') ->label('Resolve')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->requiresConfirmation() ->requiresConfirmation()
->form([ ->form([
Textarea::make('resolved_reason') Textarea::make('resolved_reason')
@ -1326,6 +1316,7 @@ public static function closeAction(): Actions\Action
->label('Close') ->label('Close')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('danger') ->color('danger')
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->requiresConfirmation() ->requiresConfirmation()
->form([ ->form([
Textarea::make('closed_reason') Textarea::make('closed_reason')
@ -1353,36 +1344,153 @@ public static function closeAction(): Actions\Action
->apply(); ->apply();
} }
public static function riskAcceptAction(): Actions\Action public static function requestExceptionAction(): Actions\Action
{ {
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('risk_accept') Actions\Action::make('request_exception')
->label('Risk accept') ->label('Request exception')
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-exclamation')
->color('warning') ->color('warning')
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->requiresConfirmation() ->requiresConfirmation()
->form([ ->form([
Textarea::make('closed_reason') Select::make('owner_user_id')
->label('Risk acceptance reason') ->label('Owner')
->rows(3)
->required() ->required()
->maxLength(255), ->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Request reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { ->action(function (Finding $record, array $data, FindingExceptionService $service): void {
static::runWorkflowMutation( static::runExceptionRequestMutation($record, $data, $service);
record: $record,
successTitle: 'Finding marked as risk accepted',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
$finding,
$tenant,
$user,
(string) ($data['closed_reason'] ?? ''),
),
);
}) })
) )
->preserveVisibility() ->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT) ->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function renewExceptionAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false)
->fillForm(fn (Finding $record): array => [
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
])
->requiresConfirmation()
->form([
Select::make('owner_user_id')
->label('Owner')
->required()
->options(fn (): array => static::tenantMemberOptions())
->searchable(),
Textarea::make('request_reason')
->label('Renewal reason')
->rows(4)
->required()
->maxLength(2000),
DateTimePicker::make('review_due_at')
->label('Review due at')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Requested expiry')
->seconds(false),
Repeater::make('evidence_references')
->label('Evidence references')
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('source_type')
->label('Source type')
->required()
->maxLength(255),
TextInput::make('source_id')
->label('Source ID')
->maxLength(255),
TextInput::make('source_fingerprint')
->label('Fingerprint')
->maxLength(255),
DateTimePicker::make('measured_at')
->label('Measured at')
->seconds(false),
])
->defaultItems(0)
->collapsed(),
])
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
static::runExceptionRenewalMutation($record, $data, $service);
})
)
->preserveVisibility()
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
public static function revokeExceptionAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('revoke_exception')
->label('Revoke exception')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
->requiresConfirmation()
->form([
Textarea::make('revocation_reason')
->label('Revocation reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
static::runExceptionRevocationMutation($record, $data, $service);
})
)
->preserveVisibility()
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(); ->apply();
} }
@ -1395,7 +1503,7 @@ public static function reopenAction(): Actions\Action
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->color('warning') ->color('warning')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) ->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
->action(function (Finding $record, FindingWorkflowService $workflow): void { ->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation( static::runWorkflowMutation(
record: $record, record: $record,
@ -1415,7 +1523,8 @@ public static function reopenAction(): Actions\Action
*/ */
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
{ {
$tenant = Tenant::current(); $record = static::resolveProtectedFindingRecordOrFail($record);
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -1431,6 +1540,15 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
return; return;
} }
if ((int) $record->workspace_id !== (int) $tenant->workspace_id) {
Notification::make()
->title('Finding belongs to a different workspace')
->danger()
->send();
return;
}
try { try {
$callback($record, $tenant, $user); $callback($record, $tenant, $user);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
@ -1449,12 +1567,200 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
->send(); ->send();
} }
/**
* @param array<string, mixed> $data
*/
private static function runExceptionRequestMutation(Finding $record, array $data, FindingExceptionService $service): void
{
$record = static::resolveProtectedFindingRecordOrFail($record);
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
try {
$createdException = $service->request($record, $tenant, $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception request submitted')
->success()
->actions([
Actions\Action::make('view_exception')
->label('View exception')
->url(static::findingExceptionViewUrl($createdException, $tenant)),
])
->send();
}
/**
* @param array<string, mixed> $data
*/
private static function runExceptionRenewalMutation(Finding $record, array $data, FindingExceptionService $service): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
try {
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Renewal request failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Renewal request submitted')
->success()
->actions([
Actions\Action::make('view_exception')
->label('View exception')
->url(static::findingExceptionViewUrl($renewedException, $tenant)),
])
->send();
}
/**
* @param array<string, mixed> $data
*/
private static function runExceptionRevocationMutation(Finding $record, array $data, FindingExceptionService $service): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
try {
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
} catch (InvalidArgumentException $exception) {
Notification::make()
->title('Exception revocation failed')
->body($exception->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Exception revoked')
->success()
->actions([
Actions\Action::make('view_exception')
->label('View exception')
->url(static::findingExceptionViewUrl($revokedException, $tenant)),
])
->send();
}
private static function freshWorkflowRecord(Finding $record): Finding
{
return static::resolveProtectedFindingRecordOrFail($record);
}
private static function freshWorkflowStatus(Finding $record): string
{
return (string) static::freshWorkflowRecord($record)->status;
}
private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record): Finding
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof Finding) {
abort(404);
}
return $resolvedRecord;
}
private static function currentFindingException(Finding $record): ?FindingException
{
$finding = static::resolveProtectedFindingRecordOrFail($record);
return static::resolvedFindingException($finding);
}
private static function resolvedFindingException(Finding $finding): ?FindingException
{
$exception = $finding->relationLoaded('findingException')
? $finding->findingException
: $finding->findingException()->with('currentDecision')->first();
if (! $exception instanceof FindingException) {
return null;
}
$exception->loadMissing('currentDecision');
return $exception;
}
private static function resolveCurrentFindingExceptionOrFail(Finding $record): FindingException
{
$exception = static::currentFindingException($record);
if (! $exception instanceof FindingException) {
throw new InvalidArgumentException('This finding does not have an exception to manage.');
}
return $exception;
}
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, Tenant $tenant): string
{
$panelId = Filament::getCurrentPanel()?->getId();
if ($panelId === 'admin') {
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
}
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
}
private static function governanceWarning(Finding $finding): ?string
{
return app(FindingRiskGovernanceResolver::class)
->resolveWarningMessage($finding, static::resolvedFindingException($finding));
}
private static function governanceWarningColor(Finding $finding): string
{
$exception = static::resolvedFindingException($finding);
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
return 'warning';
}
return 'danger';
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
private static function tenantMemberOptions(): array private static function tenantMemberOptions(): array
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return []; return [];

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\FindingResource\Pages; namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner; use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Jobs\BackfillFindingLifecycleJob; use App\Jobs\BackfillFindingLifecycleJob;
@ -11,6 +12,7 @@
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -21,13 +23,40 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Throwable; use Throwable;
class ListFindings extends ListRecords class ListFindings extends ListRecords
{ {
use ResolvesPanelTenantContext;
protected static string $resource = FindingResource::class; protected static string $resource = FindingResource::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, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'request_exception', 'reopen'], true)) {
try {
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {
abort(404);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function mount(): void
{
$this->syncCanonicalAdminTenantFilterState();
parent::mount();
}
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
{ {
return [ return [
@ -55,7 +84,7 @@ protected function getHeaderActions(): array
abort(403); abort(403);
} }
$tenant = \Filament\Facades\Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
abort(404); abort(404);
@ -161,7 +190,7 @@ protected function getHeaderActions(): array
} }
$user = auth()->user(); $user = auth()->user();
$tenant = \Filament\Facades\Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
@ -230,15 +259,7 @@ protected function getHeaderActions(): array
protected function buildAllMatchingQuery(): Builder protected function buildAllMatchingQuery(): Builder
{ {
$query = Finding::query(); $query = FindingResource::getEloquentQuery();
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
if (! is_numeric($tenantId)) {
return $query->whereRaw('1 = 0');
}
$query->where('tenant_id', (int) $tenantId);
$query->where('status', Finding::STATUS_NEW); $query->where('status', Finding::STATUS_NEW);
@ -288,6 +309,16 @@ protected function buildAllMatchingQuery(): Builder
return $query; return $query;
} }
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: ['scope_key', 'run_ids'],
request: request(),
tenantFilterName: null,
);
}
private function filterIsActive(string $filterName): bool private function filterIsActive(string $filterName): bool
{ {
$state = $this->getTableFilterState($filterName); $state = $this->getTableFilterState($filterName);

View File

@ -8,11 +8,17 @@
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
class ViewFinding extends ViewRecord class ViewFinding extends ViewRecord
{ {
protected static string $resource = FindingResource::class; protected static string $resource = FindingResource::class;
protected function resolveRecord(int|string $key): Model
{
return FindingResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [

View File

@ -3,6 +3,8 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
@ -23,6 +25,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -36,6 +39,9 @@
class InventoryItemResource extends Resource class InventoryItemResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = InventoryItem::class; protected static ?string $model = InventoryItem::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -48,6 +54,15 @@ class InventoryItemResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
@ -60,7 +75,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -75,7 +90,7 @@ public static function canViewAny(): bool
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -175,7 +190,7 @@ public static function infolist(Schema $schema): Schema
$service = app(DependencyQueryService::class); $service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class); $resolver = app(DependencyTargetResolver::class);
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$edges = collect(); $edges = collect();
if ($direction === 'inbound' || $direction === 'all') { if ($direction === 'inbound' || $direction === 'all') {
@ -321,13 +336,15 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::current()?->getKey(); return static::getTenantOwnedEloquentQuery()
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->with('lastSeenRun'); ->with('lastSeenRun');
} }
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\InventoryItemResource\Pages; namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Jobs\RunInventorySyncJob; use App\Jobs\RunInventorySyncJob;
@ -11,6 +12,7 @@
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -28,8 +30,21 @@
class ListInventoryItems extends ListRecords class ListInventoryItems extends ListRecords
{ {
use ResolvesPanelTenantContext;
protected static string $resource = InventoryItemResource::class; protected static string $resource = InventoryItemResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
{ {
return [ return [
@ -103,7 +118,7 @@ protected function getHeaderActions(): array
->rules(['boolean']) ->rules(['boolean'])
->columnSpanFull(), ->columnSpanFull(),
Hidden::make('tenant_id') Hidden::make('tenant_id')
->default(fn (): ?string => Tenant::current()?->getKey()) ->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
->dehydrated(), ->dehydrated(),
]) ])
->visible(function (): bool { ->visible(function (): bool {
@ -112,7 +127,7 @@ protected function getHeaderActions(): array
return false; return false;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return false; return false;
} }
@ -120,7 +135,7 @@ protected function getHeaderActions(): array
return $user->canAccessTenant($tenant); return $user->canAccessTenant($tenant);
}) })
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -159,6 +174,8 @@ protected function getHeaderActions(): array
], ],
context: array_merge($computed['selection'], [ context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'target_scope' => [ 'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(), 'entra_tenant_id' => $tenant->graphTenantId(),
], ],

View File

@ -4,8 +4,14 @@
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewInventoryItem extends ViewRecord class ViewInventoryItem extends ViewRecord
{ {
protected static string $resource = InventoryItemResource::class; protected static string $resource = InventoryItemResource::class;
protected function resolveRecord(int|string $key): Model
{
return InventoryItemResource::resolveScopedRecordOrFail($key);
}
} }

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\VerificationCheckAcknowledgement; use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
@ -21,12 +22,17 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -151,20 +157,13 @@ public static function table(Table $table): Table
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('tenant_id') Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant') ->label('Tenant')
->options(function (): array { ->options(function (): array {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
return [
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
];
}
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -212,13 +211,9 @@ public static function table(Table $table): Table
return FilterOptionCatalog::operationTypes(array_keys($types)); return FilterOptionCatalog::operationTypes(array_keys($types));
}), }),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options([ ->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
OperationRunStatus::Queued->value => 'Queued',
OperationRunStatus::Running->value => 'Running',
OperationRunStatus::Completed->value => 'Completed',
]),
Tables\Filters\SelectFilter::make('outcome') Tables\Filters\SelectFilter::make('outcome')
->options(OperationRunOutcome::uiLabels(includeReserved: false)), ->options(BadgeCatalog::options(BadgeDomain::OperationRunOutcome, OperationRunOutcome::values(includeReserved: false))),
Tables\Filters\SelectFilter::make('initiator_name') Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator') ->label('Initiator')
->options(function (): array { ->options(function (): array {
@ -228,14 +223,8 @@ public static function table(Table $table): Table
return []; return [];
} }
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
? (int) $tenant->getKey()
: null;
return OperationRun::query() return OperationRun::query()
->where('workspace_id', (int) $workspaceId) ->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
->whereNotNull('initiator_name') ->whereNotNull('initiator_name')
->select('initiator_name') ->select('initiator_name')
->distinct() ->distinct()
@ -268,6 +257,20 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome); $outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
$targetScope = static::targetScopeDisplay($record); $targetScope = static::targetScopeDisplay($record);
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []); $summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null;
$artifactTruth = $record->isGovernanceArtifactOperation()
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$artifactTruthBadge = $artifactTruth !== null
? $factory->statusBadge(
$artifactTruth->primaryBadgeSpec()->label,
$artifactTruth->primaryBadgeSpec()->color,
$artifactTruth->primaryBadgeSpec()->icon,
$artifactTruth->primaryBadgeSpec()->iconColor,
)
: null;
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData( ->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -297,6 +300,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)), $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
], ],
), ),
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
visible: $record->isGovernanceArtifactOperation(),
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
),
$factory->viewSection( $factory->viewSection(
id: 'related_context', id: 'related_context',
kind: 'related_context', kind: 'related_context',
@ -314,7 +326,43 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
items: array_values(array_filter([ items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)), $factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$artifactTruth !== null
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
: null,
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
$artifactTruth !== null
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
: null,
OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null, $summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null, RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])), ])),
), ),
@ -361,7 +409,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->viewSection( $factory->viewSection(
id: 'failures', id: 'failures',
kind: 'operational_context', kind: 'operational_context',
title: 'Failures', title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
view: 'filament.infolists.entries.snapshot-json', view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []], viewData: ['payload' => $record->failure_summary ?? []],
), ),
@ -437,12 +485,68 @@ private static function summaryCountFacts(
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []); $counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
return array_map( return array_map(
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value), static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
array_keys($counts), array_keys($counts),
array_values($counts), array_values($counts),
); );
} }
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
if ($reasonEnvelope !== null) {
return $reasonEnvelope->operatorLabel;
}
$context = is_array($record->context) ? $record->context : [];
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code')
?? data_get($record->failure_summary, '0.reason_code');
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
}
private static function blockedExecutionDetail(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
if ($reasonEnvelope !== null) {
return $reasonEnvelope->shortExplanation;
}
$message = data_get($record->failure_summary, '0.message');
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
}
private static function blockedExecutionSource(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$context = is_array($record->context) ? $record->context : [];
$blockedBy = $context['blocked_by'] ?? null;
if (! is_string($blockedBy) || trim($blockedBy) === '') {
return null;
}
return match (trim($blockedBy)) {
'queued_execution_legitimacy' => 'Execution legitimacy revalidation',
default => ucfirst(str_replace('_', ' ', trim($blockedBy))),
};
}
/** /**
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
@ -554,7 +658,7 @@ private static function verificationReportViewData(OperationRun $record): array
$previousRunUrl = null; $previousRunUrl = null;
if ($changeIndicator !== null) { if ($changeIndicator !== null) {
$tenant = Filament::getTenant(); $tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$previousRunUrl = $tenant instanceof Tenant $previousRunUrl = $tenant instanceof Tenant
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant) ? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)

View File

@ -2,6 +2,9 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyResource\Pages; use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob; use App\Jobs\BulkPolicyDeleteJob;
@ -34,6 +37,7 @@
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
@ -52,17 +56,32 @@
class PolicyResource extends Resource class PolicyResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static ?string $model = Policy::class; protected static ?string $model = Policy::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -98,7 +117,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
->modalHeading('Sync policies from Intune') ->modalHeading('Sync policies from Intune')
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.') ->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
->action(function (Pages\ListPolicies $livewire): void { ->action(function (Pages\ListPolicies $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant) { if (! $user instanceof User || ! $tenant instanceof Tenant) {
@ -488,7 +507,7 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to ignore policies.') ->tooltip('You do not have permission to ignore policies.')
@ -509,7 +528,7 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip('You do not have permission to restore policies.') ->tooltip('You do not have permission to restore policies.')
@ -523,7 +542,7 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Policy $record): bool => $record->ignored_at === null) ->visible(fn (Policy $record): bool => $record->ignored_at === null)
->action(function (Policy $record, HasTable $livewire): void { ->action(function (Policy $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
@ -569,7 +588,7 @@ public static function table(Table $table): Table
]) ])
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_SYNC) ->requireCapability(Capabilities::TENANT_SYNC)
->preserveVisibility() ->preserveVisibility()
@ -586,7 +605,7 @@ public static function table(Table $table): Table
->default(fn () => 'Backup '.now()->toDateTimeString()), ->default(fn () => 'Backup '.now()->toDateTimeString()),
]) ])
->action(function (Policy $record, array $data): void { ->action(function (Policy $record, array $data): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
@ -639,7 +658,7 @@ public static function table(Table $table): Table
]) ])
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility() ->preserveVisibility()
@ -678,7 +697,7 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -761,7 +780,7 @@ public static function table(Table $table): Table
return ! in_array($value, [null, 'ignored'], true); return ! in_array($value, [null, 'ignored'], true);
}) })
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -843,7 +862,7 @@ public static function table(Table $table): Table
return $value === 'ignored'; return $value === 'ignored';
}) })
->action(function (Collection $records, HasTable $livewire): void { ->action(function (Collection $records, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
@ -914,7 +933,7 @@ public static function table(Table $table): Table
->default(fn () => 'Backup '.now()->toDateTimeString()), ->default(fn () => 'Backup '.now()->toDateTimeString()),
]) ])
->action(function (Collection $records, array $data): void { ->action(function (Collection $records, array $data): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -995,16 +1014,25 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::currentOrFail()->getKey(); return static::getTenantOwnedEloquentQuery()
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->withCount('versions') ->withCount('versions')
->with([ ->with([
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1), 'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
]); ]);
} }
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->withCount('versions')
->with([
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
]),
);
}
public static function getRelations(): array public static function getRelations(): array
{ {
return [ return [

View File

@ -3,12 +3,20 @@
namespace App\Filament\Resources\PolicyResource\Pages; namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListPolicies extends ListRecords class ListPolicies extends ListRecords
{ {
protected static string $resource = PolicyResource::class; protected static string $resource = PolicyResource::class;
public function mount(): void
{
$this->syncCanonicalAdminTenantFilterState();
parent::mount();
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
@ -22,4 +30,14 @@ protected function getTableEmptyStateActions(): array
PolicyResource::makeSyncAction(), PolicyResource::makeSyncAction(),
]; ];
} }
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: [],
request: request(),
tenantFilterName: null,
);
}
} }

View File

@ -16,6 +16,7 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ViewPolicy extends ViewRecord class ViewPolicy extends ViewRecord
@ -24,6 +25,11 @@ class ViewPolicy extends ViewRecord
protected Width|string|null $maxContentWidth = Width::Full; protected Width|string|null $maxContentWidth = Width::Full;
protected function resolveRecord(int|string $key): Model
{
return PolicyResource::resolveScopedRecordOrFail($key);
}
protected function getActions(): array protected function getActions(): array
{ {
return [$this->makeCaptureSnapshotAction()]; return [$this->makeCaptureSnapshotAction()];

View File

@ -2,7 +2,9 @@
namespace App\Filament\Resources\PolicyResource\RelationManagers; namespace App\Filament\Resources\PolicyResource\RelationManagers;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -26,8 +28,23 @@
class VersionsRelationManager extends RelationManager class VersionsRelationManager extends RelationManager
{ {
use ResolvesPanelTenantContext;
protected static string $relationship = 'versions'; protected static string $relationship = 'versions';
/**
* @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 === 'restore_to_intune' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $context['recordKey']);
}
return parent::mountAction($name, $arguments, $context);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
@ -52,8 +69,9 @@ public function table(Table $table): Table
->label('Preview only (dry-run)') ->label('Preview only (dry-run)')
->default(true), ->default(true),
]) ])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { ->action(function (mixed $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current(); $record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record);
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -110,7 +128,7 @@ public function table(Table $table): Table
return true; return true;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -130,7 +148,7 @@ public function table(Table $table): Table
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -175,4 +193,26 @@ public function table(Table $table): Table
->emptyStateHeading('No versions captured') ->emptyStateHeading('No versions captured')
->emptyStateDescription('Capture or sync this policy again to create version history entries.'); ->emptyStateDescription('Capture or sync this policy again to create version history entries.');
} }
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
{
$recordId = $record instanceof PolicyVersion
? (int) $record->getKey()
: (is_numeric($record) ? (int) $record : 0);
if ($recordId <= 0) {
abort(404);
}
$resolvedRecord = $policy->versions()
->where('tenant_id', (int) $policy->tenant_id)
->whereKey($recordId)
->first();
if (! $resolvedRecord instanceof PolicyVersion) {
abort(404);
}
return $resolvedRecord;
}
} }

View File

@ -2,6 +2,9 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyVersionResource\Pages; use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionPruneJob;
@ -39,6 +42,7 @@
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -57,17 +61,32 @@
class PolicyVersionResource extends Resource class PolicyVersionResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static ?string $model = PolicyVersion::class; protected static ?string $model = PolicyVersion::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -274,7 +293,7 @@ public static function table(Table $table): Table
return $fields; return $fields;
}) })
->action(function (Collection $records, array $data) { ->action(function (Collection $records, array $data) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -353,7 +372,7 @@ public static function table(Table $table): Table
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?") ->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.') ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -437,7 +456,7 @@ public static function table(Table $table): Table
]), ]),
]) ])
->action(function (Collection $records, array $data) { ->action(function (Collection $records, array $data) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = $records->pluck('id')->toArray();
@ -546,7 +565,7 @@ public static function table(Table $table): Table
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->visible(function (): bool { ->visible(function (): bool {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -563,7 +582,7 @@ public static function table(Table $table): Table
return true; return true;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -580,7 +599,7 @@ public static function table(Table $table): Table
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
}) })
->tooltip(function (PolicyVersion $record): ?string { ->tooltip(function (PolicyVersion $record): ?string {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -605,7 +624,7 @@ public static function table(Table $table): Table
return null; return null;
}) })
->action(function (PolicyVersion $record) { ->action(function (PolicyVersion $record) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -877,8 +896,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenant = Tenant::currentOrFail(); $tenant = static::resolveTenantContextForCurrentPanelOrFail();
$tenantId = $tenant->getKey();
$user = auth()->user(); $user = auth()->user();
$resolver = app(CapabilityResolver::class); $resolver = app(CapabilityResolver::class);
@ -888,8 +906,7 @@ public static function getEloquentQuery(): Builder
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW) || $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
); );
return parent::getEloquentQuery() return static::getTenantOwnedEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder { ->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
@ -903,6 +920,36 @@ public static function getEloquentQuery(): Builder
->with('policy'); ->with('policy');
} }
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$user = auth()->user();
$resolver = app(CapabilityResolver::class);
$canSeeBaselinePurposeEvidence = $user instanceof User
&& (
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
);
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->withTrashed()
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void {
$query
->whereNull('capture_purpose')
->orWhereNotIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
]);
});
})
->with('policy'),
);
}
/** /**
* @return list<array{ * @return list<array{
* key: string, * key: string,

View File

@ -3,12 +3,24 @@
namespace App\Filament\Resources\PolicyVersionResource\Pages; namespace App\Filament\Resources\PolicyVersionResource\Pages;
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListPolicyVersions extends ListRecords class ListPolicyVersions extends ListRecords
{ {
protected static string $resource = PolicyVersionResource::class; protected static string $resource = PolicyVersionResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [];

View File

@ -9,6 +9,7 @@
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
class ViewPolicyVersion extends ViewRecord class ViewPolicyVersion extends ViewRecord
{ {
@ -16,6 +17,11 @@ class ViewPolicyVersion extends ViewRecord
protected Width|string|null $maxContentWidth = Width::Full; protected Width|string|null $maxContentWidth = Width::Full;
protected function resolveRecord(int|string $key): Model
{
return PolicyVersionResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\ProviderConnectionResource\Pages; use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob; use App\Jobs\ProviderInventorySyncJob;
@ -11,7 +12,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -21,6 +22,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -30,9 +32,10 @@
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@ -49,6 +52,8 @@
class ProviderConnectionResource extends Resource class ProviderConnectionResource extends Resource
{ {
use ResolvesPanelTenantContext;
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false; protected static bool $isScopedToTenant = false;
@ -147,9 +152,7 @@ protected static function resolveScopedTenant(): ?Tenant
return static::resolveTenantByExternalId($contextTenantExternalId); return static::resolveTenantByExternalId($contextTenantExternalId);
} }
$filamentTenant = Filament::getTenant(); return static::resolveTenantContextForCurrentPanel();
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
} }
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
@ -196,10 +199,10 @@ public static function resolveContextTenantExternalId(): ?string
} }
} }
$filamentTenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
if ($filamentTenant instanceof Tenant) { if ($tenant instanceof Tenant) {
return (string) $filamentTenant->external_id; return (string) $tenant->external_id;
} }
return null; return null;
@ -329,10 +332,10 @@ private static function applyMembershipScope(Builder $query): Builder
$user = auth()->user(); $user = auth()->user();
if (! is_int($workspaceId)) { if (! is_int($workspaceId)) {
$filamentTenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
if ($filamentTenant instanceof Tenant) { if ($tenant instanceof Tenant) {
$workspaceId = (int) $filamentTenant->workspace_id; $workspaceId = (int) $tenant->workspace_id;
} }
} }
@ -401,6 +404,96 @@ private static function sanitizeErrorMessage(?string $value): ?string
return Str::limit($normalized, 120); return Str::limit($normalized, 120);
} }
private static function providerConnectionTypeLabel(?ProviderConnection $record): string
{
$connectionType = $record?->connection_type;
if ($connectionType instanceof ProviderConnectionType && $connectionType === ProviderConnectionType::Dedicated) {
return 'Dedicated connection';
}
return 'Platform connection';
}
private static function effectiveAppId(?ProviderConnection $record): string
{
$effectiveAppId = $record?->effectiveAppId();
return filled($effectiveAppId) ? (string) $effectiveAppId : 'Effective app pending review';
}
private static function credentialSourceLabel(?ProviderConnection $record): string
{
if (! $record instanceof ProviderConnection) {
return 'Managed centrally by platform';
}
$effectiveApp = $record->effectiveAppMetadata();
return match ($effectiveApp['source'] ?? null) {
'dedicated_credential' => 'Dedicated credential',
'review_required' => 'Legacy identity review required',
default => 'Managed centrally by platform',
};
}
private static function migrationReviewLabel(?ProviderConnection $record): string
{
return $record?->requiresMigrationReview() ? 'Review required' : 'Clear';
}
private static function migrationReviewDescription(?ProviderConnection $record): ?string
{
if (! $record instanceof ProviderConnection) {
return null;
}
$metadata = $record->legacyIdentityMetadata();
$source = $metadata['legacy_identity_classification_source'] ?? null;
$result = $metadata['legacy_identity_result'] ?? null;
if (! $record->requiresMigrationReview()) {
return filled($source) ? sprintf('Classified via %s as %s.', $source, $result ?? 'platform') : null;
}
$signals = $metadata['legacy_identity_signals'] ?? [];
$tenantClientId = $signals['tenant_client_id'] ?? null;
$credentialClientId = $signals['credential_client_id'] ?? null;
if (filled($tenantClientId) && filled($credentialClientId)) {
return sprintf('Legacy tenant app %s conflicts with dedicated app %s.', $tenantClientId, $credentialClientId);
}
return 'Legacy app evidence conflicts with the current connection and needs explicit review.';
}
private static function consentStatusLabelFromState(mixed $state): string
{
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
return match ($value) {
'required' => 'Required',
'granted' => 'Granted',
'failed' => 'Failed',
'revoked' => 'Revoked',
default => 'Unknown',
};
}
private static function verificationStatusLabelFromState(mixed $state): string
{
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
return match ($value) {
'pending' => 'Pending',
'healthy' => 'Healthy',
'degraded' => 'Degraded',
'blocked' => 'Blocked',
'error' => 'Error',
default => 'Unknown',
};
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -418,6 +511,15 @@ public static function form(Schema $schema): Schema
->maxLength(255) ->maxLength(255)
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']), ->rules(['uuid']),
Placeholder::make('connection_type_display')
->label('Connection type')
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
Placeholder::make('platform_app_id_display')
->label('Effective app ID')
->content(fn (?ProviderConnection $record): string => static::effectiveAppId($record)),
Placeholder::make('effective_app_source_display')
->label('Effective app source')
->content(fn (?ProviderConnection $record): string => static::credentialSourceLabel($record)),
Toggle::make('is_default') Toggle::make('is_default')
->label('Default connection') ->label('Default connection')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
@ -427,6 +529,12 @@ public static function form(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Status') Section::make('Status')
->schema([ ->schema([
Placeholder::make('consent_status_display')
->label('Consent')
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
Placeholder::make('verification_status_display')
->label('Verification')
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
TextInput::make('status') TextInput::make('status')
->label('Status') ->label('Status')
->disabled() ->disabled()
@ -435,17 +543,69 @@ public static function form(Schema $schema): Schema
->label('Health') ->label('Health')
->disabled() ->disabled()
->dehydrated(false), ->dehydrated(false),
Placeholder::make('migration_review_status_display')
->label('Migration review')
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
]); ]);
} }
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Connection')
->schema([
Infolists\Components\TextEntry::make('display_name')
->label('Display name'),
Infolists\Components\TextEntry::make('provider')
->label('Provider'),
Infolists\Components\TextEntry::make('entra_tenant_id')
->label('Entra tenant ID')
->copyable(),
Infolists\Components\TextEntry::make('connection_type')
->label('Connection type')
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
? 'Dedicated connection'
: 'Platform connection'),
Infolists\Components\TextEntry::make('effective_app_id')
->label('Effective app ID')
->state(fn (ProviderConnection $record): string => static::effectiveAppId($record))
->copyable(),
Infolists\Components\TextEntry::make('effective_app_source')
->label('Effective app source')
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
])
->columns(2),
Section::make('Status')
->schema([
Infolists\Components\TextEntry::make('consent_status')
->label('Consent')
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)),
Infolists\Components\TextEntry::make('verification_status')
->label('Verification')
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)),
Infolists\Components\TextEntry::make('status')
->label('Status'),
Infolists\Components\TextEntry::make('health_status')
->label('Health'),
Infolists\Components\TextEntry::make('migration_review_required')
->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
])
->columns(2),
]);
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(function (Builder $query): Builder { ->modifyQueryUsing(function (Builder $query): Builder {
$query->with('tenant'); $query->with(['tenant', 'credential']);
$tenantExternalId = static::resolveRequestedTenantExternalId(); $tenantExternalId = static::resolveRequestedTenantExternalId();
@ -488,6 +648,13 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(), Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
Tables\Columns\TextColumn::make('connection_type')
->label('Connection type')
->badge()
->formatStateUsing(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated
? 'Dedicated'
: 'Platform')
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->label('Status') ->label('Status')
->badge() ->badge()
@ -502,6 +669,12 @@ public static function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Tables\Columns\TextColumn::make('migration_review_required')
->label('Migration review')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
->color(fn (bool $state): string => $state ? 'warning' : 'success')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(), Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
Tables\Columns\TextColumn::make('last_error_reason_code') Tables\Columns\TextColumn::make('last_error_reason_code')
->label('Last error reason') ->label('Last error reason')
@ -651,9 +824,12 @@ public static function table(Table $table): Table
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Connection check blocked') ->title('Connection check blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
@ -748,9 +924,12 @@ public static function table(Table $table): Table
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Inventory sync blocked') ->title('Inventory sync blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
@ -842,9 +1021,12 @@ public static function table(Table $table): Table
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Compliance snapshot blocked') ->title('Compliance snapshot blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
@ -920,31 +1102,32 @@ public static function table(Table $table): Table
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('update_credentials') Actions\Action::make('enable_dedicated_override')
->label('Update credentials') ->label('Enable dedicated override')
->icon('heroicon-o-key') ->icon('heroicon-o-key')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->modalDescription('Client secret is stored encrypted and will never be shown again.') ->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
->visible(fn (ProviderConnection $record): bool => $record->connection_type !== ProviderConnectionType::Dedicated)
->form([ ->form([
TextInput::make('client_id') TextInput::make('client_id')
->label('Client ID') ->label('Dedicated app (client) ID')
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('client_secret') TextInput::make('client_secret')
->label('Client secret') ->label('Dedicated client secret')
->password() ->password()
->required() ->required()
->maxLength(255), ->maxLength(255),
]) ])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { ->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
} }
$credentials->upsertClientSecretCredential( $mutations->enableDedicatedOverride(
connection: $record, connection: $record,
clientId: (string) $data['client_id'], clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'], clientSecret: (string) $data['client_secret'],
@ -957,12 +1140,16 @@ public static function table(Table $table): Table
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.credentials_updated', action: 'provider_connection.connection_type_changed',
context: [ context: [
'metadata' => [ 'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'], 'client_id' => (string) $data['client_id'],
'source' => 'provider_connection.resource_table',
], ],
], ],
actorId: $actorId, actorId: $actorId,
@ -974,12 +1161,138 @@ public static function table(Table $table): Table
); );
Notification::make() Notification::make()
->title('Credentials updated') ->title('Dedicated override enabled')
->success() ->success()
->send(); ->send();
}) })
) )
->requireCapability(Capabilities::PROVIDER_MANAGE) ->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('rotate_dedicated_credential')
->label('Rotate dedicated credential')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->default(function (ProviderConnection $record): string {
$payload = $record->credential?->payload;
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
})
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
$tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) {
return;
}
$mutations->enableDedicatedOverride(
connection: $record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
Notification::make()
->title('Dedicated credential rotated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('delete_dedicated_credential')
->label('Delete dedicated credential')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated
&& $record->credential()->exists())
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
$tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) {
return;
}
$mutations->deleteDedicatedCredential($record);
Notification::make()
->title('Dedicated credential deleted')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('revert_to_platform')
->label('Revert to platform')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->connection_type === ProviderConnectionType::Dedicated)
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record);
if (! $tenant instanceof Tenant) {
return;
}
$mutations->revertToPlatform($record);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => 'provider_connection.resource_table',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Connection reverted to platform')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(

View File

@ -6,6 +6,11 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
@ -25,12 +30,31 @@ protected function mutateFormDataBeforeCreate(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
return [ return [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'], 'entra_tenant_id' => $data['entra_tenant_id'],
'display_name' => $data['display_name'], 'display_name' => $data['display_name'],
'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null,
'consent_last_checked_at' => null,
'consent_error_code' => null,
'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false,
'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'last_error_message' => null,
'is_default' => false, 'is_default' => false,
]; ];
} }
@ -57,6 +81,7 @@ protected function afterCreate(): void
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'connection_type' => $record->connection_type->value,
], ],
], ],
actorId: $actorId, actorId: $actorId,

View File

@ -11,13 +11,14 @@
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -277,9 +278,12 @@ protected function getHeaderActions(): array
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Connection check blocked') ->title('Connection check blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
@ -310,32 +314,33 @@ protected function getHeaderActions(): array
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('update_credentials') Action::make('enable_dedicated_override')
->label('Update credentials') ->label('Enable dedicated override')
->icon('heroicon-o-key') ->icon('heroicon-o-key')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->modalDescription('Client secret is stored encrypted and will never be shown again.') ->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
->visible(fn (): bool => $tenant instanceof Tenant) ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type !== ProviderConnectionType::Dedicated)
->form([ ->form([
TextInput::make('client_id') TextInput::make('client_id')
->label('Client ID') ->label('Dedicated app (client) ID')
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('client_secret') TextInput::make('client_secret')
->label('Client secret') ->label('Dedicated client secret')
->password() ->password()
->required() ->required()
->maxLength(255), ->maxLength(255),
]) ])
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { ->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
abort(404); abort(404);
} }
$credentials->upsertClientSecretCredential( $mutations->enableDedicatedOverride(
connection: $record, connection: $record,
clientId: (string) $data['client_id'], clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'], clientSecret: (string) $data['client_secret'],
@ -348,12 +353,16 @@ protected function getHeaderActions(): array
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.credentials_updated', action: 'provider_connection.connection_type_changed',
context: [ context: [
'metadata' => [ 'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'], 'client_id' => (string) $data['client_id'],
'source' => 'provider_connection.edit_page',
], ],
], ],
actorId: $actorId, actorId: $actorId,
@ -365,13 +374,143 @@ protected function getHeaderActions(): array
); );
Notification::make() Notification::make()
->title('Credentials updated') ->title('Dedicated override enabled')
->success() ->success()
->send(); ->send();
}) })
) )
->requireCapability(Capabilities::PROVIDER_MANAGE) ->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->tooltip('You do not have permission to manage provider connections.') ->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('rotate_dedicated_credential')
->label('Rotate dedicated credential')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalDescription('Stores a replacement dedicated client secret and refreshes dedicated identity state.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type === ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->default(function (ProviderConnection $record): string {
$payload = $record->credential?->payload;
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
})
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
Notification::make()
->title('Dedicated credential rotated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('delete_dedicated_credential')
->label('Delete dedicated credential')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type === ProviderConnectionType::Dedicated
&& $record->credential()->exists())
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->deleteDedicatedCredential($record);
Notification::make()
->title('Dedicated credential deleted')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('revert_to_platform')
->label('Revert to platform')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->modalDescription('Reverts the connection to the platform-managed identity and removes any dedicated credential.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type === ProviderConnectionType::Dedicated)
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->revertToPlatform($record);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => 'provider_connection.edit_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Connection reverted to platform')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility() ->preserveVisibility()
->apply(), ->apply(),
@ -511,9 +650,12 @@ protected function getHeaderActions(): array
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Inventory sync blocked') ->title('Inventory sync blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
@ -622,9 +764,12 @@ protected function getHeaderActions(): array
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Compliance snapshot blocked') ->title('Compliance snapshot blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')

View File

@ -7,14 +7,26 @@
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListProviderConnections extends ListRecords class ListProviderConnections extends ListRecords
{ {
protected static string $resource = ProviderConnectionResource::class; protected static string $resource = ProviderConnectionResource::class;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: 'tenant',
tenantAttribute: 'external_id',
);
parent::mount();
}
private function tableHasRecords(): bool private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; return $this->getTableRecords()->count() > 0;
@ -207,9 +219,7 @@ private function resolveTenantExternalIdForCreateAction(): ?string
return $requested; return $requested;
} }
$filamentTenant = Filament::getTenant(); return ProviderConnectionResource::resolveContextTenantExternalId();
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
} }
private function resolveTenantForCreateAction(): ?Tenant private function resolveTenantForCreateAction(): ?Tenant
@ -227,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
public function getTableEmptyStateHeading(): ?string public function getTableEmptyStateHeading(): ?string
{ {
return 'No provider connections found'; return 'No Microsoft connections found';
} }
public function getTableEmptyStateDescription(): ?string public function getTableEmptyStateDescription(): ?string
{ {
return 'Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.'; return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
} }
public function getTableEmptyStateActions(): array public function getTableEmptyStateActions(): array

View File

@ -3,9 +3,17 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages; namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
class ViewProviderConnection extends ViewRecord class ViewProviderConnection extends ViewRecord
@ -15,6 +23,24 @@ class ViewProviderConnection extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
UiEnforcement::forAction(
Actions\Action::make('grant_admin_consent')
->label('Grant admin consent')
->icon('heroicon-o-clipboard-document')
->url(function (): ?string {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
return $tenant instanceof Tenant
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
: null;
})
->visible(function (): bool {
return ProviderConnectionResource::resolveTenantForRecord($this->record) instanceof Tenant;
})
->openUrlInNewTab()
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('edit') Actions\Action::make('edit')
->label('Edit') ->label('Edit')
@ -23,6 +49,201 @@ protected function getHeaderActions(): array
) )
->requireCapability(Capabilities::PROVIDER_MANAGE) ->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(), ->apply(),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('enable_dedicated_override')
->label('Enable dedicated override')
->icon('heroicon-o-key')
->color('primary')
->requiresConfirmation()
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
->visible(fn (): bool => $this->record->connection_type !== ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $this->record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $this->record->getKey(),
'provider' => $this->record->provider,
'entra_tenant_id' => $this->record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'],
'source' => 'provider_connection.view_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $this->record->getKey(),
status: 'success',
);
Notification::make()
->title('Dedicated override enabled')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('rotate_dedicated_credential')
->label('Rotate dedicated credential')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->default(function (): string {
$payload = $this->record->credential?->payload;
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
})
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnectionMutationService $mutations): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $this->record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
Notification::make()
->title('Dedicated credential rotated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('delete_dedicated_credential')
->label('Delete dedicated credential')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated
&& $this->record->credential()->exists())
->action(function (ProviderConnectionMutationService $mutations): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->deleteDedicatedCredential($this->record);
Notification::make()
->title('Dedicated credential deleted')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('revert_to_platform')
->label('Revert to platform')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
->action(function (ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->revertToPlatform($this->record);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $this->record->getKey(),
'provider' => $this->record->provider,
'entra_tenant_id' => $this->record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => 'provider_connection.view_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $this->record->getKey(),
status: 'success',
);
Notification::make()
->title('Connection reverted to platform')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
])
->label('Manage dedicated override')
->icon('heroicon-o-cog-6-tooth')
->color('gray'),
]; ];
} }
} }

View File

@ -4,6 +4,8 @@
use App\Contracts\Hardening\WriteGateInterface; use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired; use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource\Pages;
use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob;
@ -65,6 +67,9 @@
class RestoreRunResource extends Resource class RestoreRunResource extends Resource
{ {
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = RestoreRun::class; protected static ?string $model = RestoreRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant'; protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -84,7 +89,7 @@ public static function shouldRegisterNavigation(): bool
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -105,7 +110,7 @@ public static function form(Schema $schema): Schema
Forms\Components\Select::make('backup_set_id') Forms\Components\Select::make('backup_set_id')
->label('Backup set') ->label('Backup set')
->options(function () { ->options(function () {
$tenantId = Tenant::currentOrFail()->getKey(); $tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return BackupSet::query() return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
@ -145,7 +150,7 @@ public static function form(Schema $schema): Schema
->schema(function (Get $get): array { ->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id'); $backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids'); $selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) { if (! $tenant || ! $backupSetId) {
return []; return [];
@ -199,7 +204,7 @@ public static function form(Schema $schema): Schema
->label('Sync Groups') ->label('Sync Groups')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('warning')
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
->visible(fn (): bool => $cacheNotice !== null) ->visible(fn (): bool => $cacheNotice !== null)
); );
}, $unresolved); }, $unresolved);
@ -207,7 +212,7 @@ public static function form(Schema $schema): Schema
->visible(function (Get $get): bool { ->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id'); $backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids'); $selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) { if (! $tenant || ! $backupSetId) {
return false; return false;
@ -239,18 +244,44 @@ public static function makeCreateAction(): Actions\CreateAction
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::current()?->getKey(); return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
->with('backupSet');
}
return parent::getEloquentQuery() public static function resolveScopedRecordOrFail(int|string $key): Model
->with('backupSet') {
->when( return static::resolveTenantOwnedRecordOrFail(
$tenantId !== null, $key,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantId), parent::getEloquentQuery()->withTrashed()->with('backupSet'),
) );
->when( }
$tenantId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'), protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
); {
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof RestoreRun) {
abort(404);
}
return $resolvedRecord;
}
/**
* @return array<int, int>
*/
protected static function resolveProtectedRestoreRunIds(Collection $records): array
{
return $records
->map(function (mixed $record): int {
$resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0));
return (int) $resolvedRecord->getKey();
})
->filter(fn (int $value): bool => $value > 0)
->unique()
->values()
->all();
} }
/** /**
@ -265,7 +296,7 @@ public static function getWizardSteps(): array
Forms\Components\Select::make('backup_set_id') Forms\Components\Select::make('backup_set_id')
->label('Backup set') ->label('Backup set')
->options(function () { ->options(function () {
$tenantId = Tenant::currentOrFail()->getKey(); $tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return BackupSet::query() return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
@ -290,7 +321,7 @@ public static function getWizardSteps(): array
backupSetId: $get('backup_set_id'), backupSetId: $get('backup_set_id'),
scopeMode: 'all', scopeMode: 'all',
selectedItemIds: null, selectedItemIds: null,
tenant: Tenant::current(), tenant: static::resolveTenantContextForCurrentPanel(),
)); ));
$set('is_dry_run', true); $set('is_dry_run', true);
$set('acknowledged_impact', false); $set('acknowledged_impact', false);
@ -317,7 +348,7 @@ public static function getWizardSteps(): array
->reactive() ->reactive()
->afterStateUpdated(function (Set $set, Get $get, $state): void { ->afterStateUpdated(function (Set $set, Get $get, $state): void {
$backupSetId = $get('backup_set_id'); $backupSetId = $get('backup_set_id');
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$set('is_dry_run', true); $set('is_dry_run', true);
$set('acknowledged_impact', false); $set('acknowledged_impact', false);
$set('tenant_confirm', null); $set('tenant_confirm', null);
@ -357,7 +388,7 @@ public static function getWizardSteps(): array
$backupSetId = $get('backup_set_id'); $backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids'); $selectedItemIds = $get('backup_item_ids');
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null; $selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$set('group_mapping', static::groupMappingPlaceholders( $set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId, backupSetId: $backupSetId,
@ -408,7 +439,7 @@ public static function getWizardSteps(): array
$backupSetId = $get('backup_set_id'); $backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all'; $scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids'); $selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) { if (! $tenant || ! $backupSetId) {
return []; return [];
@ -477,7 +508,7 @@ public static function getWizardSteps(): array
->label('Sync Groups') ->label('Sync Groups')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('warning')
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
->visible(fn (): bool => $cacheNotice !== null) ->visible(fn (): bool => $cacheNotice !== null)
); );
}, $unresolved); }, $unresolved);
@ -486,7 +517,7 @@ public static function getWizardSteps(): array
$backupSetId = $get('backup_set_id'); $backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all'; $scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids'); $selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) { if (! $tenant || ! $backupSetId) {
return false; return false;
@ -527,7 +558,7 @@ public static function getWizardSteps(): array
->color('gray') ->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id'))) ->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void { ->action(function (Get $get, Set $set): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return; return;
@ -625,7 +656,7 @@ public static function getWizardSteps(): array
->color('gray') ->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id'))) ->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void { ->action(function (Get $get, Set $set): void {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return; return;
@ -704,7 +735,7 @@ public static function getWizardSteps(): array
Forms\Components\Placeholder::make('confirm_tenant_label') Forms\Components\Placeholder::make('confirm_tenant_label')
->label('Tenant hard-confirm label') ->label('Tenant hard-confirm label')
->content(function (): string { ->content(function (): string {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return ''; return '';
@ -741,7 +772,7 @@ public static function getWizardSteps(): array
->required(fn (Get $get): bool => $get('is_dry_run') === false) ->required(fn (Get $get): bool => $get('is_dry_run') === false)
->visible(fn (Get $get): bool => $get('is_dry_run') === false) ->visible(fn (Get $get): bool => $get('is_dry_run') === false)
->in(function (): array { ->in(function (): array {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return []; return [];
@ -755,7 +786,7 @@ public static function getWizardSteps(): array
'in' => 'Tenant hard-confirm does not match.', 'in' => 'Tenant hard-confirm does not match.',
]) ])
->helperText(function (): string { ->helperText(function (): string {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return ''; return '';
@ -793,10 +824,10 @@ public static function table(Table $table): Table
->label('Total') ->label('Total')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
Tables\Columns\TextColumn::make('summary_succeeded') Tables\Columns\TextColumn::make('summary_succeeded')
->label('Succeeded') ->label('Applied')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
Tables\Columns\TextColumn::make('summary_failed') Tables\Columns\TextColumn::make('summary_failed')
->label('Failed') ->label('Failed items')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
@ -843,6 +874,8 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed()) ->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$record->restore(); $record->restore();
if ($record->tenant) { if ($record->tenant) {
@ -861,7 +894,7 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility() ->preserveVisibility()
@ -874,6 +907,8 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => ! $record->trashed()) ->visible(fn (RestoreRun $record): bool => ! $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if (! $record->isDeletable()) { if (! $record->isDeletable()) {
Notification::make() Notification::make()
->title('Restore run cannot be archived') ->title('Restore run cannot be archived')
@ -902,7 +937,7 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility() ->preserveVisibility()
@ -915,6 +950,8 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed()) ->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if ($record->tenant) { if ($record->tenant) {
$auditLogger->log( $auditLogger->log(
tenant: $record->tenant, tenant: $record->tenant,
@ -933,7 +970,7 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)
->preserveVisibility() ->preserveVisibility()
@ -972,10 +1009,10 @@ public static function table(Table $table): Table
return []; return [];
}) })
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = static::resolveProtectedRestoreRunIds($records);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
@ -1042,10 +1079,10 @@ public static function table(Table $table): Table
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?") ->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.') ->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = static::resolveProtectedRestoreRunIds($records);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
@ -1132,10 +1169,10 @@ public static function table(Table $table): Table
]), ]),
]) ])
->action(function (Collection $records) { ->action(function (Collection $records) {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
$ids = $records->pluck('id')->toArray(); $ids = static::resolveProtectedRestoreRunIds($records);
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return; return;
@ -1224,7 +1261,7 @@ public static function infolist(Schema $schema): Schema
$succeeded = (int) ($meta['succeeded'] ?? 0); $succeeded = (int) ($meta['succeeded'] ?? 0);
$failed = (int) ($meta['failed'] ?? 0); $failed = (int) ($meta['failed'] ?? 0);
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed); return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
}), }),
Infolists\Components\TextEntry::make('is_dry_run') Infolists\Components\TextEntry::make('is_dry_run')
->label('Dry-run') ->label('Dry-run')
@ -1276,7 +1313,7 @@ private static function typeMeta(?string $type): array
*/ */
private static function restoreItemOptionData(?int $backupSetId): array private static function restoreItemOptionData(?int $backupSetId): array
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) { if (! $tenant || ! $backupSetId) {
return [ return [
@ -1347,7 +1384,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
*/ */
private static function restoreItemGroupedOptions(?int $backupSetId): array private static function restoreItemGroupedOptions(?int $backupSetId): array
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) { if (! $tenant || ! $backupSetId) {
return []; return [];
@ -1399,7 +1436,7 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
public static function createRestoreRun(array $data): RestoreRun public static function createRestoreRun(array $data): RestoreRun
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
@ -1691,6 +1728,8 @@ public static function createRestoreRun(array $data): RestoreRun
'restore_run_id' => (int) $restoreRun->getKey(), 'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
initiator: $initiator, initiator: $initiator,
); );
@ -1922,6 +1961,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
\App\Services\Intune\AuditLogger $auditLogger, \App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire HasTable $livewire
) { ) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$tenant = $record->tenant; $tenant = $record->tenant;
$backupSet = $record->backupSet; $backupSet = $record->backupSet;
@ -2089,6 +2129,8 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
'restore_run_id' => (int) $newRun->getKey(), 'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false), 'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
initiator: $initiator, initiator: $initiator,
); );
@ -2165,7 +2207,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
OperationUxPresenter::queuedToast('restore.execute') OperationUxPresenter::queuedToast('restore.execute')
->send(); ->send();
}), }),
fn () => Tenant::current(), fn () => static::resolveTenantContextForCurrentPanel(),
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility() ->preserveVisibility()
@ -2181,7 +2223,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
return true; return true;
} }
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current(); $tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return true; return true;
@ -2205,7 +2247,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
return \App\Support\Auth\UiTooltips::insufficientPermission(); return \App\Support\Auth\UiTooltips::insufficientPermission();
} }
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current(); $tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return 'Tenant unavailable'; return 'Tenant unavailable';

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
@ -17,12 +18,13 @@
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
{ {
use HasWizard; use HasWizard;
use ResolvesPanelTenantContext;
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
protected function authorizeAccess(): void protected function authorizeAccess(): void
{ {
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -60,7 +62,7 @@ protected function afterFill(): void
return; return;
} }
$tenant = Tenant::current(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) { if (! $tenant) {
return; return;

View File

@ -3,12 +3,42 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ListRestoreRuns extends ListRecords class ListRestoreRuns extends ListRecords
{ {
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::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', 'forceDelete', 'rerun'], true)) {
try {
RestoreRunResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {
abort(404);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
private function tableHasRecords(): bool private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; return $this->getTableRecords()->count() > 0;

View File

@ -4,8 +4,14 @@
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewRestoreRun extends ViewRecord class ViewRestoreRun extends ViewRecord
{ {
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
protected function resolveRecord(int|string $key): Model
{
return RestoreRunResource::resolveScopedRecordOrFail($key);
}
} }

View File

@ -2,12 +2,15 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages; use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -17,11 +20,14 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@ -109,6 +115,15 @@ public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Status') Section::make('Status')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
@ -164,6 +179,21 @@ public static function infolist(Schema $schema): Schema
Section::make('Metadata') Section::make('Metadata')
->schema([ ->schema([
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'), TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
TextEntry::make('tenantReview.id')
->label('Tenant review')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (ReviewPack $record): ?string => $record->tenantReview && $record->tenant
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
: null)
->placeholder('—'),
TextEntry::make('summary.review_status')
->label('Review status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->placeholder('—'),
TextEntry::make('operationRun.id') TextEntry::make('operationRun.id')
->label('Operation run') ->label('Operation run')
->url(fn (ReviewPack $record): ?string => $record->operation_run_id ->url(fn (ReviewPack $record): ?string => $record->operation_run_id
@ -177,6 +207,33 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Evidence snapshot')
->schema([
TextEntry::make('summary.evidence_resolution.outcome')
->label('Resolution')
->placeholder('—'),
TextEntry::make('evidenceSnapshot.id')
->label('Snapshot')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null),
TextEntry::make('evidenceSnapshot.completeness_state')
->label('Snapshot completeness')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
->placeholder('—'),
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
->label('Snapshot fingerprint')
->copyable()
->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
]); ]);
} }
@ -194,6 +251,15 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('tenant.name') Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant') ->label('Tenant')
->searchable(), ->searchable(),
@ -201,6 +267,10 @@ public static function table(Table $table): Table
->dateTime() ->dateTime()
->sortable() ->sortable()
->placeholder('—'), ->placeholder('—'),
Tables\Columns\TextColumn::make('tenantReview.id')
->label('Review')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('expires_at') Tables\Columns\TextColumn::make('expires_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
@ -209,6 +279,29 @@ public static function table(Table $table): Table
->label('Size') ->label('Size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—') ->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('publication_truth')
->label('Publication')
->badge()
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->label('Created') ->label('Created')
->since() ->since()
@ -304,6 +397,11 @@ public static function getPages(): array
]; ];
} }
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
@ -331,7 +429,23 @@ public static function executeGeneration(array $data): void
'include_operations' => (bool) ($data['include_operations'] ?? true), 'include_operations' => (bool) ($data['include_operations'] ?? true),
]; ];
$reviewPack = $service->generate($tenant, $user, $options); try {
$reviewPack = $service->generate($tenant, $user, $options);
} catch (ReviewPackEvidenceResolutionException $exception) {
$reasons = $exception->result->reasons;
Notification::make()
->danger()
->title(match ($exception->result->outcome) {
'missing_snapshot' => 'Create snapshot required',
'snapshot_ineligible' => 'Snapshot is not eligible',
default => 'Unable to generate review pack',
})
->body($reasons === [] ? $exception->getMessage() : implode(' ', $reasons))
->send();
return;
}
if (! $reviewPack->wasRecentlyCreated) { if (! $reviewPack->wasRecentlyCreated) {
Notification::make() Notification::make()

View File

@ -11,7 +11,9 @@
use App\Models\EntraRoleDefinition; use App\Models\EntraRoleDefinition;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
@ -21,7 +23,11 @@
use App\Services\Intune\RbacOnboardingService; use App\Services\Intune\RbacOnboardingService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\Tenants\TenantActionPolicySurface;
use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
@ -33,6 +39,12 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -76,6 +88,11 @@ class TenantResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Settings'; protected static string|UnitEnum|null $navigationGroup = 'Settings';
/**
* @var array<string, Collection<int, TenantActionDescriptor>>
*/
protected static array $tenantActionCatalogCache = [];
/** /**
* Tenant creation is handled exclusively by the onboarding wizard. * Tenant creation is handled exclusively by the onboarding wizard.
* The CRUD create page has been removed. * The CRUD create page has been removed.
@ -129,9 +146,10 @@ public static function canDeleteAny(): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->withListRowPrimaryActionLimit(2)
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.') ->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
@ -207,6 +225,18 @@ public static function getEloquentQuery(): Builder
->withMax('policies as last_policy_sync_at', 'last_synced_at'); ->withMax('policies as last_policy_sync_at', 'last_synced_at');
} }
public static function getGlobalSearchEloquentQuery(): Builder
{
if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) {
return static::getEloquentQuery()->whereRaw('1 = 0');
}
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
static::getEloquentQuery(),
(new Tenant)->getTable(),
);
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -242,20 +272,23 @@ public static function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_current') Tables\Columns\IconColumn::make('is_current')
->label('Current') ->label('Current')
->boolean(), ->boolean()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('app_status') Tables\Columns\TextColumn::make('app_status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->since() ->since()
@ -284,11 +317,56 @@ public static function table(Table $table): Table
]), ]),
]) ])
->actions([ ->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
->color('danger')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::archiveTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
ActionGroup::make([ ActionGroup::make([
Actions\Action::make('view') Actions\Action::make('related_onboarding_overflow')
->label('View') ->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
->icon('heroicon-o-eye') ->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])), ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('syncTenant') Actions\Action::make('syncTenant')
->label('Sync') ->label('Sync')
@ -415,46 +493,9 @@ public static function table(Table $table): Table
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->successNotificationTitle('Tenant reactivated')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->restore();
$auditLogger->log(
tenant: $record,
action: 'tenant.restored',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('admin_consent') Actions\Action::make('admin_consent')
->label('Admin consent') ->label('Grant admin consent')
->icon('heroicon-o-clipboard-document') ->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
@ -475,7 +516,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive()) ->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
->action(function ( ->action(function (
Tenant $record, Tenant $record,
StartVerification $verification, StartVerification $verification,
@ -567,9 +608,12 @@ public static function table(Table $table): Table
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();
@ -592,48 +636,6 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::PROVIDER_RUN) ->requireCapability(Capabilities::PROVIDER_RUN)
->apply(), ->apply(),
static::rbacAction(), static::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('forceDelete') Actions\Action::make('forceDelete')
->label('Force delete') ->label('Force delete')
@ -838,6 +840,10 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('lifecycle_summary')
->label('Lifecycle summary')
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
->columnSpanFull(),
Infolists\Components\TextEntry::make('app_status') Infolists\Components\TextEntry::make('app_status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
@ -905,8 +911,20 @@ public static function infolist(Schema $schema): Schema
->visible(fn (Tenant $record): bool => filled($record->rbac_status)), ->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Section::make('RBAC Details') Section::make('RBAC Details')
->schema([ ->schema([
Infolists\Components\TextEntry::make('rbac_status_reason_label')
->label('Reason')
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
->visible(fn (?string $state): bool => filled($state)),
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
->label('Explanation')
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
->visible(fn (?string $state): bool => filled($state))
->columnSpanFull(),
Infolists\Components\TextEntry::make('rbac_status_reason') Infolists\Components\TextEntry::make('rbac_status_reason')
->label('Reason'), ->label('Diagnostic code')
->copyable(),
Infolists\Components\TextEntry::make('rbac_role_definition_id') Infolists\Components\TextEntry::make('rbac_role_definition_id')
->label('Role definition ID') ->label('Role definition ID')
->copyable(), ->copyable(),
@ -936,7 +954,7 @@ public static function infolist(Schema $schema): Schema
Section::make('Integration') Section::make('Integration')
->schema([ ->schema([
Infolists\Components\TextEntry::make('admin_consent_url') Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL') ->label('Grant admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record)) ->state(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (?string $state) => filled($state)) ->visible(fn (?string $state) => filled($state))
->copyable() ->copyable()
@ -1013,6 +1031,216 @@ protected static function storedPermissionSnapshot(Tenant $tenant): array
return $snapshot; return $snapshot;
} }
protected static function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromTenant($tenant);
}
public static function tenantOperability(): TenantOperabilityService
{
return app(TenantOperabilityService::class);
}
public static function tenantActionPolicy(): TenantActionPolicySurface
{
return app(TenantActionPolicySurface::class);
}
/**
* @return Collection<int, TenantActionDescriptor>
*/
public static function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): Collection
{
$cacheKey = static::tenantActionCatalogCacheKey($tenant, $surface);
if (! array_key_exists($cacheKey, static::$tenantActionCatalogCache)) {
static::$tenantActionCatalogCache[$cacheKey] = collect(static::tenantActionPolicy()->catalogForTenant($tenant, $surface))->values();
}
return static::$tenantActionCatalogCache[$cacheKey];
}
public static function lifecycleActionDescriptor(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): ?TenantActionDescriptor
{
return static::tenantActionDescriptorForSurface($tenant, $surface, 'restore')
?? static::tenantActionDescriptorForSurface($tenant, $surface, 'archive');
}
public static function tenantIndexPrimaryAction(Tenant $tenant): ?TenantActionDescriptor
{
$catalog = static::tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow);
return $catalog[1] ?? null;
}
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
{
return static::tenantActionPolicy()->relatedOnboardingDraft($tenant);
}
public static function relatedOnboardingDraftAction(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?TenantActionDescriptor
{
return static::tenantActionDescriptorForSurface($tenant, $surface, 'related_onboarding');
}
public static function relatedOnboardingDraftActionLabel(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?string
{
return static::relatedOnboardingDraftAction($tenant, $surface)?->label;
}
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
{
$draft = static::relatedOnboardingDraft($tenant);
if (! $draft instanceof TenantOnboardingSession) {
return null;
}
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
}
public static function verificationActionVisible(Tenant $tenant): bool
{
$outcome = static::verificationReadinessOutcome($tenant);
return $outcome->allowed || $outcome->isDeniedForCapability();
}
public static function verificationReadinessOutcome(Tenant $tenant): TenantOperabilityOutcome
{
$user = auth()->user();
return static::tenantOperability()->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
actor: $user instanceof User ? $user : null,
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
lane: TenantInteractionLane::AdministrativeManagement,
);
}
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
{
$descriptor = static::tenantActionCatalog($tenant, $surface)
->first(fn (TenantActionDescriptor $descriptor): bool => $descriptor->key === $key);
return $descriptor instanceof TenantActionDescriptor ? $descriptor : null;
}
private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantActionSurface $surface): string
{
$relatedDraft = static::relatedOnboardingDraft($tenant);
return implode(':', [
(string) (auth()->id() ?? 'guest'),
$surface->value,
(string) ($tenant->getKey() ?? 'missing'),
(string) $tenant->status,
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
(string) ($relatedDraft?->workflowStatus()->value ?? 'no-draft-status'),
(string) ($relatedDraft?->lifecycleState()->value ?? 'no-draft-lifecycle-state'),
(string) ($relatedDraft?->updated_at?->getTimestamp() ?? 'no-draft-updated-at'),
(string) ($relatedDraft?->completed_at?->getTimestamp() ?? 'no-draft-completed-at'),
(string) ($relatedDraft?->cancelled_at?->getTimestamp() ?? 'no-draft-cancelled-at'),
]);
}
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$descriptor = static::lifecycleActionDescriptor($record);
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'archive') {
Notification::make()
->title('Archive unavailable')
->body('Only active tenants can be archived from tenant management surfaces.')
->warning()
->send();
return;
}
$record->delete();
$auditLogger->logTenantLifecycleAction(
tenant: $record,
action: AuditActionId::TenantArchived,
actor: $user,
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title($descriptor->successNotificationTitle ?? 'Tenant archived')
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
->success()
->send();
}
public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$descriptor = static::lifecycleActionDescriptor($record);
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'restore') {
Notification::make()
->title('Restore unavailable')
->body('Only archived tenants can be restored from tenant management surfaces.')
->warning()
->send();
return;
}
$record->restore();
$auditLogger->logTenantLifecycleAction(
tenant: $record,
action: AuditActionId::TenantRestored,
actor: $user,
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title($descriptor->successNotificationTitle ?? 'Tenant restored')
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
->success()
->send();
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
@ -1231,36 +1459,6 @@ public static function rbacAction(): Actions\Action
} }
public static function adminConsentUrl(Tenant $tenant): ?string public static function adminConsentUrl(Tenant $tenant): ?string
{
$tenantId = $tenant->graphTenantId();
$clientId = $tenant->app_client_id;
if (! is_string($clientId) || trim($clientId) === '') {
$clientId = static::resolveProviderClientIdForConsent($tenant);
}
$redirectUri = route('admin.consent.callback');
$state = sprintf('tenantpilot|%s', $tenant->id);
if (! $tenantId || ! $clientId || ! $redirectUri) {
return null;
}
// Admin consent should use `.default` so the tenant consents to the app's configured
// application permissions. Keeping the URL short also avoids edge cases where a long
// scope string gets truncated and causes AADSTS900144 (missing `scope`).
$scopes = 'https://graph.microsoft.com/.default';
$query = http_build_query([
'client_id' => $clientId,
'state' => $state,
'redirect_uri' => $redirectUri,
'scope' => $scopes,
]);
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
}
private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string
{ {
$connection = ProviderConnection::query() $connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -1273,21 +1471,11 @@ private static function resolveProviderClientIdForConsent(Tenant $tenant): ?stri
return null; return null;
} }
$payload = $connection->credential?->payload; try {
return app(AdminConsentUrlFactory::class)->make($connection, sprintf('tenantpilot|%s', $tenant->id));
if (! is_array($payload)) { } catch (\Throwable) {
return null; return null;
} }
$clientId = $payload['client_id'] ?? null;
if (! is_string($clientId)) {
return null;
}
$clientId = trim($clientId);
return $clientId !== '' ? $clientId : null;
} }
/** /**
@ -1340,10 +1528,21 @@ private static function providerConnectionState(Tenant $tenant): array
public static function entraUrl(Tenant $tenant): ?string public static function entraUrl(Tenant $tenant): ?string
{ {
if ($tenant->app_client_id) { $connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->orderByDesc('is_default')
->orderBy('id')
->first();
$effectiveAppId = $connection instanceof ProviderConnection
? $connection->effectiveAppId()
: null;
if (filled($effectiveAppId)) {
return sprintf( return sprintf(
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s', 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s',
$tenant->app_client_id $effectiveAppId
); );
} }

View File

@ -4,8 +4,10 @@
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionSurface;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@ -18,14 +20,40 @@ protected function getHeaderActions(): array
{ {
return [ return [
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
UiEnforcement::forAction(
Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::restoreTenant($record, $auditLogger);
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to restore tenants.')
->preserveVisibility()
->destructive()
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Action::make('archive') Action::make('archive')
->label('Archive') ->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
->color('danger') ->color('danger')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed()) ->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
->action(function (Tenant $record): void { ->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
$record->delete(); ->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::archiveTenant($record, $auditLogger);
}) })
) )
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)

View File

@ -1,8 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantResource\Pages; namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -13,10 +19,7 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\Action::make('add_tenant') $this->makeOnboardingEntryAction()
->label('Add tenant')
->icon('heroicon-m-plus')
->url(route('admin.onboarding'))
->visible(fn (): bool => $this->getTableRecords()->count() > 0), ->visible(fn (): bool => $this->getTableRecords()->count() > 0),
]; ];
} }
@ -24,10 +27,40 @@ protected function getHeaderActions(): array
protected function getTableEmptyStateActions(): array protected function getTableEmptyStateActions(): array
{ {
return [ return [
Actions\Action::make('add_tenant') $this->makeOnboardingEntryAction(),
->label('Add tenant')
->icon('heroicon-m-plus')
->url(route('admin.onboarding')),
]; ];
} }
private function makeOnboardingEntryAction(): Actions\Action
{
$descriptor = TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
return Actions\Action::make('add_tenant')
->label($descriptor->label)
->icon($descriptor->icon)
->url(route('admin.onboarding'));
}
private function accessibleResumableDraftCount(): int
{
$user = auth()->user();
if (! $user instanceof User) {
return 0;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return 0;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return 0;
}
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $workspace)->count();
}
} }

View File

@ -11,7 +11,7 @@
use App\Jobs\RefreshTenantRbacHealthJob; use App\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -20,6 +20,7 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionSurface;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
@ -63,8 +64,13 @@ protected function getHeaderActions(): array
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
Actions\Action::make('admin_consent') Actions\Action::make('admin_consent')
->label('Admin consent') ->label('Grant admin consent')
->icon('heroicon-o-clipboard-document') ->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
@ -81,7 +87,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive()) ->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
->action(function ( ->action(function (
Tenant $record, Tenant $record,
StartVerification $verification, StartVerification $verification,
@ -172,9 +178,12 @@ protected function getHeaderActions(): array
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();
@ -264,34 +273,34 @@ protected function getHeaderActions(): array
->preserveVisibility() ->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN) ->requireCapability(Capabilities::PROVIDER_RUN)
->apply(), ->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('archive') Actions\Action::make('archive')
->label('Deactivate') ->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
->color('danger') ->color('danger')
->icon('heroicon-o-archive-box-x-mark') ->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->visible(fn (Tenant $record): bool => ! $record->trashed()) ->requiresConfirmation()
->action(function (Tenant $record, AuditLogger $auditLogger): void { ->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Archive tenant')
$record->delete(); ->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'archive')
$auditLogger->log( ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
tenant: $record, TenantResource::archiveTenant($record, $auditLogger);
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'internal_tenant_id' => (int) $record->getKey(),
'tenant_guid' => (string) $record->tenant_id,
],
]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}) })
) )
->preserveVisibility() ->preserveVisibility()

View File

@ -0,0 +1,612 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\TenantReviewSection;
use App\Models\User;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
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\Forms\Components\Select;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Enums\TextSize;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use UnitEnum;
class TenantReviewResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static bool $isDiscovered = false;
protected static ?string $model = TenantReview::class;
protected static ?string $slug = 'reviews';
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = 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 ?int $navigationSort = 45;
public static function shouldRegisterNavigation(): bool
{
return Filament::getCurrentPanel()?->getId() === 'tenant';
}
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::TENANT_REVIEW_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
return false;
}
return $user->can('view', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
}
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
->latest('generated_at')
->latest('id');
}
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 (TenantReview $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Review')
->schema([
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextEntry::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)),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('published_at')->dateTime()->placeholder('—'),
TextEntry::make('evidenceSnapshot.id')
->label('Evidence snapshot')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null),
TextEntry::make('currentExportReviewPack.id')
->label('Current export')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
: null),
TextEntry::make('fingerprint')
->copyable()
->placeholder('—')
->columnSpanFull()
->fontFamily('mono')
->size(TextSize::ExtraSmall),
])
->columns(2)
->columnSpanFull(),
Section::make('Executive posture')
->schema([
ViewEntry::make('review_summary')
->hiddenLabel()
->view('filament.infolists.entries.tenant-review-summary')
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Sections')
->schema([
RepeatableEntry::make('sections')
->hiddenLabel()
->schema([
TextEntry::make('title'),
TextEntry::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)),
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
Section::make('Details')
->schema([
ViewEntry::make('section_payload')
->hiddenLabel()
->view('filament.infolists.entries.tenant-review-section')
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
->columnSpanFull(),
])
->collapsible()
->collapsed()
->columnSpanFull(),
])
->columns(3),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('generated_at', 'desc')
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\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))
->sortable(),
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
Tables\Columns\TextColumn::make('publication_truth')
->label('Publication')
->badge()
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export')
->boolean(),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
Tables\Columns\TextColumn::make('fingerprint')
->toggleable(isToggledHiddenByDefault: true)
->searchable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(collect(TenantReviewStatus::cases())
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
->all()),
Tables\Filters\SelectFilter::make('completeness_state')
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
])
->actions([
Actions\Action::make('view_review')
->label('View review')
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
->bulkActions([])
->emptyStateHeading('No tenant reviews yet')
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
->emptyStateActions([
static::makeCreateReviewAction(
name: 'create_first_review',
label: 'Create first review',
icon: 'heroicon-o-plus',
),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenantReviews::route('/'),
'view' => Pages\ViewTenantReview::route('/{record}'),
];
}
public static function makeCreateReviewAction(
string $name = 'create_review',
string $label = 'Create review',
string $icon = 'heroicon-o-plus',
): Actions\Action {
return UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
->icon($icon)
->form([
Section::make('Evidence basis')
->schema([
Select::make('evidence_snapshot_id')
->label('Evidence snapshot')
->required()
->options(fn (): array => static::evidenceSnapshotOptions())
->searchable()
->helperText('Choose the anchored evidence snapshot for this review.'),
]),
])
->action(fn (array $data): mixed => static::executeCreateReview($data)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->apply();
}
/**
* @param array<string, mixed> $data
*/
public static function executeCreateReview(array $data): void
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
return;
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
abort(403);
}
$snapshotId = $data['evidence_snapshot_id'] ?? null;
$snapshot = is_numeric($snapshotId)
? EvidenceSnapshot::query()
->whereKey((int) $snapshotId)
->where('tenant_id', (int) $tenant->getKey())
->first()
: null;
if (! $snapshot instanceof EvidenceSnapshot) {
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
return;
}
try {
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
return;
}
if (! $review->wasRecentlyCreated) {
Notification::make()
->success()
->title('Review already available')
->body('A matching mutable review already exists for this evidence basis.')
->actions([
Actions\Action::make('view_review')
->label('View review')
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
])
->send();
return;
}
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
->body('The review is being composed in the background.');
if ($review->operation_run_id) {
$toast->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]);
}
$toast->send();
}
public static function executeExport(TenantReview $review): void
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
$user = auth()->user();
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
return;
}
if (! $user->canAccessTenant($review->tenant)) {
abort(404);
}
if (! $user->can('export', $review)) {
abort(403);
}
$service = app(ReviewPackService::class);
if ($service->checkActiveRunForReview($review)) {
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
->body('An executive pack export is already queued or running for this review.')
->send();
return;
}
try {
$pack = $service->generateFromReview($review, $user, [
'include_pii' => true,
'include_operations' => true,
]);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
return;
}
if (! $pack->wasRecentlyCreated) {
Notification::make()
->success()
->title('Executive pack already available')
->body('A matching executive pack already exists for this review.')
->actions([
Actions\Action::make('view_pack')
->label('View pack')
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
])
->send();
return;
}
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body('The executive pack is being generated in the background.')
->send();
}
/**
* @param array<string, mixed> $parameters
*/
public static function tenantScopedUrl(
string $page = 'index',
array $parameters = [],
?Tenant $tenant = null,
?string $panel = null,
): string {
$panelId = $panel ?? 'tenant';
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
}
/**
* @return array<string, string>
*/
private static function evidenceSnapshotOptions(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return EvidenceSnapshot::query()
->where('tenant_id', (int) $tenant->getKey())
->whereNotNull('generated_at')
->orderByDesc('generated_at')
->orderByDesc('id')
->get()
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
(string) $snapshot->getKey() => sprintf(
'#%d · %s · %s',
(int) $snapshot->getKey(),
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
),
])
->all();
}
private static function reviewCompletenessCountLabel(string $state): string
{
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
}
/**
* @return array<string, mixed>
*/
private static function summaryPresentation(TenantReview $record): array
{
$summary = is_array($record->summary) ? $record->summary : [];
return [
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'metrics' => [
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
],
];
}
/**
* @return array<string, mixed>
*/
private static function sectionPresentation(TenantReviewSection $section): array
{
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
$render = is_array($section->render_payload) ? $section->render_payload : [];
$review = $section->tenantReview;
$tenant = $section->tenant;
return [
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
if (is_array($value) || $value === null || $value === '') {
return null;
}
return [
'label' => Str::headline($key),
'value' => (string) $value,
];
})->filter()->values()->all(),
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
'links' => [],
];
}
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Resources\TenantReviewResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantReviews extends ListRecords
{
protected static string $resource = TenantReviewResource::class;
protected function getHeaderActions(): array
{
return [
TenantReviewResource::makeCreateReviewAction(),
];
}
}

View File

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
protected function resolveRecord(int|string $key): Model
{
return TenantReviewResource::resolveScopedRecordOrFail($key);
}
protected function authorizeAccess(): void
{
$tenant = TenantReviewResource::panelTenantContext();
$record = $this->getRecord();
$user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
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);
}
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_run')
->label('View run')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
->url(fn (): ?string => $this->record->operation_run_id
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
: null),
Actions\Action::make('view_export')
->label('View executive pack')
->icon('heroicon-o-document-arrow-down')
->color('gray')
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
->url(fn (): ?string => $this->record->currentExportReviewPack
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
: null),
Actions\Action::make('view_evidence')
->label('View evidence snapshot')
->icon('heroicon-o-shield-check')
->color('gray')
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
->url(fn (): ?string => $this->record->evidenceSnapshot
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
: null),
UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label('Refresh review')
->icon('heroicon-o-arrow-path')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewService::class)->refresh($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
return;
}
Notification::make()->success()->title('Refresh review queued')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label('Publish review')
->icon('heroicon-o-check-badge')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
return;
}
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->hidden(fn (): bool => ! in_array($this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('create_next_review')
->label('Create next review')
->icon('heroicon-o-document-duplicate')
->hidden(fn (): bool => ! $this->record->isPublished())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
return;
}
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label('Archive review')
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
->label('More')
->icon('heroicon-m-ellipsis-vertical')
->color('gray'),
];
}
}

View File

@ -107,10 +107,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-magnifying-glass') ->icon('heroicon-o-magnifying-glass')
->form($this->findingsScopeForm()) ->form($this->findingsScopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = FindingsLifecycleBackfillScope::fromArray([ $scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
$this->findingsScopeMode = $scope->mode; $this->findingsScopeMode = $scope->mode;
$this->findingsTenantId = $scope->tenantId; $this->findingsTenantId = $scope->tenantId;
@ -142,9 +139,7 @@ protected function getHeaderActions(): array
]); ]);
} }
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT $scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
: FindingsLifecycleBackfillScope::allTenants();
$user = auth('platform')->user(); $user = auth('platform')->user();
@ -286,4 +281,34 @@ private function lastRunForType(string $type): ?OperationRun
->latest('id') ->latest('id')
->first(); ->first();
} }
/**
* @param array<string, mixed> $data
*/
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
if (! $scope->isSingleTenant()) {
return $scope;
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
return FindingsLifecycleBackfillScope::allTenants();
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
} }

View File

@ -22,6 +22,8 @@ protected function getViewData(): array
$empty = [ $empty = [
'hasAssignment' => false, 'hasAssignment' => false,
'state' => 'no_assignment',
'message' => null,
'profileName' => null, 'profileName' => null,
'findingsCount' => 0, 'findingsCount' => 0,
'highCount' => 0, 'highCount' => 0,
@ -43,6 +45,8 @@ protected function getViewData(): array
return [ return [
'hasAssignment' => true, 'hasAssignment' => true,
'state' => $stats->state,
'message' => $stats->message,
'profileName' => $stats->profileName, 'profileName' => $stats->profileName,
'findingsCount' => $stats->findingsCount ?? 0, 'findingsCount' => $stats->findingsCount ?? 0,
'highCount' => $stats->severityCounts['high'] ?? 0, 'highCount' => $stats->severityCounts['high'] ?? 0,

View File

@ -6,7 +6,6 @@
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
@ -42,16 +41,7 @@ public function table(Table $table): Table
->label('Subject') ->label('Subject')
->placeholder('—') ->placeholder('—')
->limit(40) ->limit(40)
->formatStateUsing(function (?string $state, Finding $record): ?string { ->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
if (is_string($state) && trim($state) !== '') {
return $state;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
})
->description(function (Finding $record): ?string { ->description(function (Finding $record): ?string {
if (Arr::get($record->evidence_jsonb ?? [], 'summary.kind') !== 'rbac_role_definition') { if (Arr::get($record->evidence_jsonb ?? [], 'summary.kind') !== 'rbac_role_definition') {
return null; return null;
@ -59,17 +49,7 @@ public function table(Table $table): Table
return __('findings.drift.rbac_role_definition'); return __('findings.drift.rbac_role_definition');
}) })
->tooltip(function (Finding $record): ?string { ->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
$displayName = $record->subject_display_name;
if (is_string($displayName) && trim($displayName) !== '') {
return $displayName;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
return is_string($fallback) && trim($fallback) !== '' ? trim($fallback) : null;
}),
TextColumn::make('severity') TextColumn::make('severity')
->badge() ->badge()
->sortable() ->sortable()
@ -106,13 +86,7 @@ private function getQuery(): Builder
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null; $tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
return Finding::query() return Finding::query()
->addSelect([ ->withSubjectDisplayName()
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->where('finding_type', Finding::FINDING_TYPE_DRIFT) ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->latest('created_at'); ->latest('created_at');

View File

@ -11,6 +11,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OperationUxPresenter;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -57,7 +58,8 @@ public function table(Table $table): Table
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Started') ->label('Started')
->sortable() ->sortable()

View File

@ -4,6 +4,7 @@
namespace App\Filament\Widgets\Inventory; namespace App\Filament\Widgets\Inventory;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -14,7 +15,6 @@
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
@ -22,6 +22,8 @@
class InventoryKpiHeader extends StatsOverviewWidget class InventoryKpiHeader extends StatsOverviewWidget
{ {
use ResolvesPanelTenantContext;
protected static bool $isLazy = false; protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';
@ -36,15 +38,15 @@ class InventoryKpiHeader extends StatsOverviewWidget
*/ */
protected function getStats(): array protected function getStats(): array
{ {
$tenant = Filament::getTenant(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return [ return [
Stat::make('Total items', 0), Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'), Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
Stat::make('Last inventory sync', '—'), Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
Stat::make('Active ops', 0), Stat::make('Active ops', 0),
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'), Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
]; ];
} }

View File

@ -6,11 +6,11 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -23,7 +23,7 @@ class OperationsKpiHeader extends StatsOverviewWidget
protected function getPollingInterval(): ?string protected function getPollingInterval(): ?string
{ {
$tenant = Filament::getTenant(); $tenant = $this->activeTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;
@ -37,7 +37,7 @@ protected function getPollingInterval(): ?string
*/ */
protected function getStats(): array protected function getStats(): array
{ {
$tenant = Filament::getTenant(); $tenant = $this->activeTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return []; return [];
@ -110,6 +110,13 @@ protected function getStats(): array
]; ];
} }
private function activeTenant(): ?Tenant
{
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
private static function formatDurationSeconds(int $seconds): string private static function formatDurationSeconds(int $seconds): string
{ {
if ($seconds <= 0) { if ($seconds <= 0) {

View File

@ -44,8 +44,10 @@ protected function getViewData(): array
} }
return [ return [
'shouldShow' => $hasWarnings && $runUrl !== null, 'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
'runUrl' => $runUrl, 'runUrl' => $runUrl,
'state' => $stats->state,
'message' => $stats->message,
'coverageStatus' => $coverageStatus, 'coverageStatus' => $coverageStatus,
'fidelity' => $stats->fidelity, 'fidelity' => $stats->fidelity,
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes), 'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),

View File

@ -5,6 +5,7 @@
namespace App\Filament\Widgets\Tenant; namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Tenants\TenantLifecyclePresentation;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
@ -23,6 +24,7 @@ protected function getViewData(): array
return [ return [
'tenant' => $tenant instanceof Tenant ? $tenant : null, 'tenant' => $tenant instanceof Tenant ? $tenant : null,
'presentation' => $tenant instanceof Tenant ? TenantLifecyclePresentation::fromTenant($tenant) : null,
]; ];
} }
} }

View File

@ -131,6 +131,7 @@ protected function getViewData(): array
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant); $canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$latestPack = ReviewPack::query() $latestPack = ReviewPack::query()
->with('tenantReview')
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at') ->orderByDesc('created_at')
->orderByDesc('id') ->orderByDesc('id')
@ -146,6 +147,7 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
'reviewUrl' => null,
]; ];
} }
@ -158,6 +160,11 @@ protected function getViewData(): array
$downloadUrl = $service->generateDownloadUrl($latestPack); $downloadUrl = $service->generateDownloadUrl($latestPack);
} }
$reviewUrl = null;
if ($latestPack->tenantReview && $canView) {
$reviewUrl = \App\Filament\Resources\TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPack->tenantReview], $tenant);
}
$failedReason = null; $failedReason = null;
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) { if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : []; $opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
@ -173,6 +180,7 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'downloadUrl' => $downloadUrl, 'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason, 'failedReason' => $failedReason,
'reviewUrl' => $reviewUrl,
]; ];
} }
@ -200,6 +208,7 @@ private function emptyState(): array
'canManage' => false, 'canManage' => false,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
'reviewUrl' => null,
]; ];
} }
} }

View File

@ -8,6 +8,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
@ -133,9 +134,12 @@ public function startVerification(StartVerification $verification): void
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();
@ -189,9 +193,15 @@ protected function getViewData(): array
$user = auth()->user(); $user = auth()->user();
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant); $isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canOperate = app(TenantOperabilityService::class)->decisionFor($tenant)->canOperate;
$canStart = $isTenantMember $canStart = $isTenantMember
&& $canOperate
&& $user->can(Capabilities::PROVIDER_RUN, $tenant); && $user->can(Capabilities::PROVIDER_RUN, $tenant);
$lifecycleNotice = $isTenantMember && ! $canOperate
? 'Verification can be started from tenant management only while the tenant is active.'
: null;
$runData = null; $runData = null;
if ($run instanceof OperationRun) { if ($run instanceof OperationRun) {
@ -220,8 +230,10 @@ protected function getViewData(): array
'report' => $report, 'report' => $report,
'redactionNotes' => VerificationReportViewer::redactionNotes($report), 'redactionNotes' => VerificationReportViewer::redactionNotes($report),
'isInProgress' => $isInProgress, 'isInProgress' => $isInProgress,
'showStartAction' => $isTenantMember && $canOperate,
'canStart' => $canStart, 'canStart' => $canStart,
'startTooltip' => $isTenantMember && ! $canStart ? UiTooltips::insufficientPermission() : null, 'startTooltip' => $isTenantMember && $canOperate && ! $canStart ? UiTooltips::insufficientPermission() : null,
'lifecycleNotice' => $lifecycleNotice,
]; ];
} }
} }

View File

@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* guidance: ?string,
* started_at: string, * started_at: string,
* url: string * url: string
* }> * }>
@ -48,6 +49,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* guidance: ?string,
* started_at: string, * started_at: string,
* url: string * url: string
* }> $operations * }> $operations

View File

@ -5,7 +5,11 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response as ResponseAlias; use Symfony\Component\HttpFoundation\Response as ResponseAlias;
@ -51,25 +55,47 @@ public function __invoke(
error: $error, error: $error,
); );
$legacyStatus = $status === 'ok' ? 'success' : 'failed';
$auditMetadata = [
'source' => 'admin.consent.callback',
'workspace_id' => (int) $connection->workspace_id,
'status' => $status,
'state' => $state,
'error' => $error,
'consent' => $consentGranted,
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'connection_type' => $connection->connection_type->value,
'consent_status' => $connection->consent_status->value,
'verification_status' => $connection->verification_status->value,
];
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'tenant.consent.callback', action: 'tenant.consent.callback',
context: [ context: [
'metadata' => [ 'metadata' => $auditMetadata,
'status' => $status,
'state' => $state,
'error' => $error,
'consent' => $consentGranted,
'provider_connection_id' => (int) $connection->getKey(),
],
], ],
status: $status === 'ok' ? 'success' : 'error', status: $legacyStatus,
resourceType: 'tenant', resourceType: 'provider_connection',
resourceId: (string) $tenant->id, resourceId: (string) $connection->getKey(),
);
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.consent_result',
context: [
'metadata' => $auditMetadata,
],
status: $legacyStatus,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
); );
return view('admin-consent-callback', [ return view('admin-consent-callback', [
'tenant' => $tenant, 'tenant' => $tenant,
'connection' => $connection,
'status' => $status, 'status' => $status,
'error' => $error, 'error' => $error,
'consentGranted' => $consentGranted, 'consentGranted' => $consentGranted,
@ -112,16 +138,19 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
->where('is_default', true) ->where('is_default', true)
->exists(); ->exists();
$connectionStatus = match ($status) { $consentStatus = match ($status) {
'ok' => 'connected', 'ok' => ProviderConsentStatus::Granted,
'error' => 'error', 'error' => ProviderConsentStatus::Failed,
'consent_denied' => 'needs_consent', default => ProviderConsentStatus::Required,
default => 'needs_consent',
}; };
$verificationStatus = ProviderVerificationStatus::Unknown;
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$reasonCode = match ($status) { $reasonCode = match ($status) {
'ok' => null, 'ok' => null,
'consent_denied' => ProviderReasonCodes::ProviderConsentMissing,
'error' => ProviderReasonCodes::ProviderAuthFailed, 'error' => ProviderReasonCodes::ProviderAuthFailed,
default => ProviderReasonCodes::ProviderConsentMissing, default => ProviderReasonCodes::ProviderConsentMissing,
}; };
@ -135,19 +164,30 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
[ [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
'status' => $connectionStatus, 'connection_type' => ProviderConnectionType::Platform->value,
'health_status' => $connectionStatus === 'connected' ? 'unknown' : 'degraded', 'status' => $projectedState['status'],
'consent_status' => $consentStatus->value,
'consent_granted_at' => $status === 'ok' ? now() : null,
'consent_last_checked_at' => now(),
'consent_error_code' => $reasonCode,
'consent_error_message' => $error,
'verification_status' => $verificationStatus->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false,
'migration_reviewed_at' => null,
'last_error_reason_code' => $reasonCode, 'last_error_reason_code' => $reasonCode,
'last_error_message' => $error, 'last_error_message' => $error,
'is_default' => $hasDefault ? false : true, 'is_default' => $hasDefault ? false : true,
], ],
); );
$connection->credential()->delete();
if (! $hasDefault && ! $connection->is_default) { if (! $hasDefault && ! $connection->is_default) {
$connection->makeDefault(); $connection->makeDefault();
} }
return $connection; return $connection->fresh();
} }
private function parseState(?string $state): ?string private function parseState(?string $state): ?string

View File

@ -4,6 +4,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -15,14 +16,31 @@ public function __invoke(Request $request): RedirectResponse
{ {
Filament::setTenant(null, true); Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId($request); $workspaceContext = app(WorkspaceContext::class);
$workspaceContext->clearRememberedTenantContext($request);
$previousUrl = url()->previous(); $previousUrl = url()->previous();
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST); $previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
$previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? '');
if ($previousHost !== null && $previousHost !== $request->getHost()) { if ($previousHost !== null && $previousHost !== $request->getHost()) {
return redirect()->to('/admin/operations'); return redirect()->route('admin.operations.index');
}
if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) {
$workspace = $workspaceContext->currentWorkspace($request);
if ($workspace !== null) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]);
}
return redirect()->route('admin.home');
}
if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') {
return redirect()->route('admin.operations.index');
} }
return redirect()->to((string) $previousUrl); return redirect()->to((string) $previousUrl);

View File

@ -8,6 +8,9 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\UserTenantPreference; use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -34,7 +37,6 @@ public function __invoke(Request $request): RedirectResponse
]); ]);
$tenant = Tenant::query() $tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $workspaceId) ->where('workspace_id', $workspaceId)
->whereKey($validated['tenant_id']) ->whereKey($validated['tenant_id'])
->first(); ->first();
@ -47,9 +49,23 @@ public function __invoke(Request $request): RedirectResponse
abort(404); 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); $this->persistLastTenant($user, $tenant);
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request); if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, $request)) {
abort(404);
}
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
} }

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