diff --git a/specs/066-rbac-ui-enforcement-helper/plan.md b/specs/066-rbac-ui-enforcement-helper/plan.md new file mode 100644 index 0000000..5715060 --- /dev/null +++ b/specs/066-rbac-ui-enforcement-helper/plan.md @@ -0,0 +1,188 @@ +# Implementation Plan: RBAC UI Enforcement Helper v1 + +**Branch**: `066-rbac-ui-enforcement-helper` | **Date**: 2026-01-28 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/066-rbac-ui-enforcement-helper/spec.md` + +## Summary + +Provide a single, centrally maintained enforcement helper (`UiEnforcement`) that codifies the RBAC-UX constitution rules for tenant-scoped Filament actions: + +- Non-member → 404 (deny-as-not-found), hidden in UI +- Member without capability → 403 on execution, visible-but-disabled in UI with standard tooltip +- Member with capability → enabled +- Destructive actions → `requiresConfirmation()` + clear warning + +The helper wraps/augments Filament Actions (header, table row, bulk) to provide default UI + server-side enforcement, and ships with regression tests + a CI-failing guard against ad-hoc authorization patterns. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4 +**Storage**: PostgreSQL (existing tables — no new tables) +**Testing**: Pest v4 (Feature + Unit tests) +**Target Platform**: Docker / Sail local, Dokploy VPS (Linux) +**Project Type**: Web / Monolith (backend + Filament admin) +**Performance Goals**: No additional DB queries beyond request-scope cached membership (FR-012) +**Constraints**: DB-only at render time (FR-013); no outbound HTTP +**Scale/Scope**: ~40+ tenant-scoped action surfaces; v1 migrates 3–6 exemplar surfaces + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| Inventory-first | N/A | No Inventory changes | +| Read/write separation | ✔ | Helper enforces existing gates; no new writes | +| Graph contract path | N/A | No Graph calls | +| Deterministic capabilities | ✔ | Uses existing `Capabilities` registry | +| RBAC-UX planes | ✔ | Tenant-plane only; cross-plane logic untouched | +| Tenant isolation | ✔ | 404 for non-members; capability check requires membership first | +| Run observability | N/A | No long-running work; helper is request-scope only | +| Data minimization | ✔ | No additional logging beyond existing deny logs | +| Badge semantics | N/A | No badge changes | + +## Existing RBAC Primitives (Research) + +| Component | Location | Purpose | +|-----------|----------|---------| +| `Capabilities` | `app/Support/Auth/Capabilities.php` | Canonical tenant capability registry (constants) | +| `PlatformCapabilities` | `app/Support/Auth/PlatformCapabilities.php` | Platform-plane capabilities | +| `RoleCapabilityMap` | `app/Services/Auth/RoleCapabilityMap.php` | Role → capabilities mapping | +| `CapabilityResolver` | `app/Services/Auth/CapabilityResolver.php` | Request-scope cached role/capability resolution | +| `User::canAccessTenant()` | `app/Models/User.php:123` | Membership check | +| `AuthServiceProvider` | `app/Providers/AuthServiceProvider.php` | Registers Gates for all capabilities | +| Existing ad-hoc patterns | `app/Filament/**` | 50+ `->visible(fn ...)` / `->disabled(fn ...)` calls — target for migration | + +## Project Structure + +### Documentation (this feature) + +```text +specs/066-rbac-ui-enforcement-helper/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # (no separate file needed — inline above) +├── data-model.md # (no schema changes) +├── quickstart.md # Adoption guide +├── checklists/ +│ └── requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +app/ +├── Support/ +│ └── Rbac/ +│ ├── UiEnforcement.php # Central facade/builder +│ ├── TenantAccessContext.php # DTO: tenant, user, isMember, capabilityCheck +│ └── UiTooltips.php # Standardized tooltip strings +├── Services/Auth/ +│ ├── CapabilityResolver.php # (existing, reused) +│ └── RoleCapabilityMap.php # (existing, reused) +├── Filament/ +│ └── Resources/... # 3–6 exemplar migrations + +tests/ +├── Feature/ +│ └── Rbac/ +│ └── UiEnforcementTest.php # Integration tests +│ └── Guards/ +│ └── NoAdHocFilamentAuthPatternsTest.php # CI-failing guard (file-scan) +├── Unit/ +│ └── Support/Rbac/ +│ └── UiEnforcementTest.php # Unit tests + +``` + +**Structure Decision**: All new code lives in `app/Support/Rbac/` (helper) + tests; no new models/tables required. + +## Key Design Decisions + +### UiEnforcement API (FR-001) + +```php +use App\Support\Rbac\UiEnforcement; + +// Basic usage +UiEnforcement::forAction($action) + ->requireMembership() // default: true + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->destructive() // optional: adds confirmation + ->apply(); + +// Table/row action (receives record or record-accessor closure) +UiEnforcement::forTableAction(Action $action, Model|Closure $record) + ->requireCapability(Capabilities::TENANT_DELETE) + ->destructive() + ->apply(); + +// Mixed visibility support (keep business visibility, add RBAC visibility) +UiEnforcement::forAction($action) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); + +// Bulk action (all-or-nothing) +UiEnforcement::forBulkAction($action) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); +``` + +Internally: +1. Resolves current tenant + user via `Filament::getTenant()` + `auth()->user()` +2. Checks membership via `CapabilityResolver` (request-scope cached) +3. Sets `->hidden()` for non-members (FR-002a) +4. Sets `->disabled()` + `->tooltip()` for members without capability (FR-004) +5. Wraps handler with server-side guard (FR-005): `abort(404)` / `abort(403)` + +### Tooltip Copy (FR-008) + +```php +class UiTooltips +{ + public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.'; + public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?'; + public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.'; +} +``` + +### Destructive Confirmation (FR-007) + +`->destructive()` calls: +- `$action->requiresConfirmation()` +- `$action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)` +- `$action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)` + +### All-or-nothing Bulk (FR-010a) + +Before rendering, bulk action checks all selected records. If any record fails capability check for the member, action is disabled. + +### Guardrail (FR-011) + +`tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` scans `app/Filament/**` for forbidden patterns like: +- `Gate::allows(...)` / `Gate::denies(...)` +- `abort_if(...)` / `abort_unless(...)` + +It uses a legacy allowlist so CI fails only for **new** violations, and the allowlist should shrink as resources are migrated. + +## v1 Migration Targets (FR-009) + +| Surface | File | Current Pattern | Notes | +|---------|------|-----------------|-------| +| TenantResource table actions | `TenantResource.php` | Multiple `->visible(fn ...)` + `->disabled(fn ...)` | High-traffic, high-value | +| ProviderConnectionResource actions | `EditProviderConnection.php` | Multiple `canAccessTenant` + capability checks inline | Complex, good test case | +| BackupSetResource table actions | `BackupSetResource.php` | Many `->disabled(fn ...)` closures | Destructive actions | +| PolicyResource ListPolicies sync | `ListPolicies.php` | Inline checks | Good example | +| EntraGroupResource sync | `ListEntraGroups.php` | Inline checks | Good example | +| FindingResource actions | `FindingResource.php` | `->authorize(fn ...)` inline | Good example | + +## Complexity Tracking + +> No constitution violations. Complexity is low (helper + tests + 3–6 migrations). + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | diff --git a/specs/066-rbac-ui-enforcement-helper/quickstart.md b/specs/066-rbac-ui-enforcement-helper/quickstart.md new file mode 100644 index 0000000..d4128c9 --- /dev/null +++ b/specs/066-rbac-ui-enforcement-helper/quickstart.md @@ -0,0 +1,262 @@ +# Quickstart: UiEnforcement Helper + +> Adoption guide for developers adding RBAC enforcement to Filament actions. + +## TL;DR + +Replace ad-hoc `->visible(fn ...)` / `->disabled(fn ...)` closures with `UiEnforcement`. + +```php +// ❌ Before (ad-hoc) +Action::make('sync') + ->visible(fn () => auth()->user()->can('provider:manage', Filament::getTenant())) + ->disabled(fn () => ! auth()->user()->can('provider:manage', Filament::getTenant())) + ->action(function () { + // no server-side guard + }); + +// ✅ After (UiEnforcement) +UiEnforcement::forAction( + Action::make('sync') + ->action(fn () => $this->sync()) +) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(); +``` + +## When to Use + +| Scenario | Use UiEnforcement? | +|----------|-------------------| +| Tenant-scoped action (header, table, bulk) | ✅ Yes | +| Platform-scoped action (/system panel) | ❌ No (use Gate directly) | +| Read-only navigation link | ❌ No (use `->visible()` for nav items) | +| Destructive action (delete, detach, restore) | ✅ Yes, with `->destructive()` | + +## API Reference + +### Header / Page Actions + +```php +use App\Support\Rbac\UiEnforcement; +use App\Support\Auth\Capabilities; + +protected function getHeaderActions(): array +{ + return [ + UiEnforcement::forAction( + Action::make('createBackup') + ->action(fn () => $this->createBackup()) + ) + ->requireCapability(Capabilities::BACKUP_CREATE) + ->apply(), + + UiEnforcement::forAction( + Action::make('deleteAllBackups') + ->action(fn () => $this->deleteAll()) + ) + ->requireCapability(Capabilities::BACKUP_MANAGE) + ->destructive() + ->apply(), + ]; +} +``` + +### Table Row Actions + +```php +public static function table(Table $table): Table +{ + return $table + ->columns([...]) + ->actions([ + UiEnforcement::forTableAction( + Action::make('restore') + ->action(fn (Policy $record) => $record->restore()), + fn () => $this->getRecord() // record accessor + ) + ->requireCapability(Capabilities::RESTORE_EXECUTE) + ->destructive() + ->apply(), + ]); +} +``` + +### Bulk Actions + +```php +->bulkActions([ + UiEnforcement::forBulkAction( + BulkAction::make('deleteSelected') + ->action(fn (Collection $records) => $records->each->delete()) + ) + ->requireCapability(Capabilities::TENANT_DELETE) + ->destructive() + ->apply(), +]) +``` + +## Behavior Matrix + +| User Status | UI State | Server Response | +|-------------|----------|-----------------| +| Non-member | Hidden | Blocked (no execution, 200) | +| Member, no capability | Visible, disabled + tooltip | Blocked (no execution, 200) | +| Member, has capability | Enabled | Executes | +| Member, destructive action | Confirmation modal | Executes after confirm | + +> **Note on 404/403 Responses:** In Filament v5, hidden actions are automatically +> treated as disabled, so execution is blocked silently (returns 200 with no side +> effects). True 404 enforcement happens at the page/routing level via tenant +> middleware. The UiEnforcement helper includes defense-in-depth server-side +> guards that abort(404/403) if somehow reached, but the primary protection is +> Filament's isHidden/isDisabled chain. + +## Tooltip Customization + +Default tooltip: *"You don't have permission to do this. Ask a tenant admin."* + +Override per-action: + +```php +UiEnforcement::forAction($action) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip('Contact your organization owner to enable this feature.') + ->apply(); +``` + +## Testing + +Test both UI state and execution blocking: + +```php +it('hides sync action for non-members', function () { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + // user is NOT a member + + actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHidden('sync'); +}); + +it('blocks action execution for non-members (no side effects)', function () { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + Queue::fake(); + + actingAs($user); + Filament::setTenant($tenant, true); + + // Hidden actions are blocked silently (200 but no execution) + Livewire::test(ListPolicies::class) + ->mountAction('sync') + ->callMountedAction() + ->assertSuccessful(); + + // Verify no side effects occurred + Queue::assertNothingPushed(); +}); + +it('disables sync action for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionVisible('sync') + ->assertActionDisabled('sync'); +}); + +it('shows disabled tooltip for readonly members', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicies::class) + ->assertActionHasTooltip('sync', UiTooltips::INSUFFICIENT_PERMISSION); +}); +``` + +## Migration Checklist + +When migrating an existing action: + +- [ ] Remove `->visible(fn ...)` closure (UiEnforcement handles this) +- [ ] Remove `->disabled(fn ...)` closure (UiEnforcement handles this) +- [ ] Remove inline `Gate::check()` / `abort_unless()` from action handler +- [ ] Wrap action with `UiEnforcement::forAction(...)->requireCapability(...)->apply()` +- [ ] Add `->destructive()` if action modifies/deletes data +- [ ] Add test for non-member (hidden + no execution) +- [ ] Add test for member without capability (disabled + tooltip) +- [ ] Add test for member with capability (enabled + executes) + +### Real Example: ListPolicies Sync Action + +```php +// Before (ad-hoc) +Action::make('sync') + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + $tenant = Tenant::current(); + if (! $tenant) { + return; + } + // ... sync logic + }) + ->disabled(fn (): bool => ! Gate::allows(Capabilities::TENANT_SYNC, Tenant::current())) + +// After (UiEnforcement) +UiEnforcement::forAction( + Action::make('sync') + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + $tenant = Tenant::current(); + if (! $tenant) { + return; + } + // ... sync logic + }) +) + ->requireCapability(Capabilities::TENANT_SYNC) + ->destructive() + ->apply() +``` + +## Common Mistakes + +### ❌ Forgetting `->apply()` + +```php +// This does nothing! +UiEnforcement::forAction($action) + ->requireCapability(Capabilities::PROVIDER_MANAGE); + // missing ->apply() +``` + +### ❌ Using with non-tenant panels + +```php +// UiEnforcement is tenant-scoped only! +// For /system panel, use Gate::check() directly +``` + +### ❌ Mixing old and new patterns + +```php +// Don't mix - pick one +UiEnforcement::forAction( + Action::make('sync') + ->visible(fn () => someOtherCheck()) // ❌ conflict +) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->apply(); +``` + +## Questions? + +See [spec.md](./spec.md) for full requirements or [plan.md](./plan.md) for implementation details. diff --git a/specs/066-rbac-ui-enforcement-helper/tasks.md b/specs/066-rbac-ui-enforcement-helper/tasks.md new file mode 100644 index 0000000..8ee4e18 --- /dev/null +++ b/specs/066-rbac-ui-enforcement-helper/tasks.md @@ -0,0 +1,254 @@ +# Tasks: RBAC UI Enforcement Helper v1 + +**Input**: Design documents from `/specs/066-rbac-ui-enforcement-helper/` +**Prerequisites**: plan.md ✓, spec.md ✓, quickstart.md ✓ + +**Tests**: REQUIRED (Pest) — this feature changes runtime authorization behavior. +**RBAC**: This feature IS the RBAC enforcement helper — all tasks enforce constitution RBAC-UX rules. + +**Organization**: Tasks grouped by user story for independent implementation. + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: US1/US2/US3 for user story phases; omitted for Setup/Foundational/Polish + +--- + +## Phase 1: Setup + +**Purpose**: Create helper infrastructure with no external dependencies + +- [X] T001 Create directory structure `app/Support/Rbac/` +- [X] T002 [P] Create `UiTooltips.php` with tooltip constants in `app/Support/Rbac/UiTooltips.php` +- [X] T003 [P] Create `TenantAccessContext.php` DTO in `app/Support/Rbac/TenantAccessContext.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core `UiEnforcement` helper — MUST complete before any user story tests + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Implement `UiEnforcement::forAction()` static method in `app/Support/Rbac/UiEnforcement.php` +- [X] T005 Implement `->requireMembership()` method (default: true) in `app/Support/Rbac/UiEnforcement.php` +- [X] T006 Implement `->requireCapability(string $capability)` method in `app/Support/Rbac/UiEnforcement.php` +- [X] T007 Implement `->destructive()` method (confirmation modal) in `app/Support/Rbac/UiEnforcement.php` +- [X] T008 Implement `->tooltip(string $message)` override method in `app/Support/Rbac/UiEnforcement.php` +- [X] T009 Implement `->apply()` method (sets hidden/disabled/guards) in `app/Support/Rbac/UiEnforcement.php` +- [X] T010 Implement `UiEnforcement::forTableAction()` static method in `app/Support/Rbac/UiEnforcement.php` +- [X] T011 Implement `UiEnforcement::forBulkAction()` static method with all-or-nothing logic in `app/Support/Rbac/UiEnforcement.php` + +**Checkpoint**: `UiEnforcement` class ready — user story tests can now be written + +--- + +## Phase 3: User Story 1 — Tenant member sees consistent disabled UX (Priority: P1) 🎯 MVP + +**Goal**: Members lacking capability see actions visible-but-disabled with standard tooltip; 403 on execution + +**Independent Test**: Visit tenant page as member with insufficient permission → action disabled with tooltip, cannot execute + +### Tests for User Story 1 + +- [X] T012 [P] [US1] Test: member without capability sees disabled action + tooltip in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php` +- [X] T013 [P] [US1] Test: member without capability is blocked from execution in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php` +- [X] T014 [P] [US1] Test: member with capability sees enabled action + can execute in `tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php` +- [X] T014a [P] [US1] Test: destructive action shows confirmation modal before execution in `tests/Feature/Rbac/UiEnforcementDestructiveTest.php` + +### Implementation for User Story 1 + +- [X] T015 [US1] Validate `->apply()` correctly sets `->disabled()` + `->tooltip()` for members lacking capability (logic in T009; this task verifies + adjusts if needed) in `app/Support/Rbac/UiEnforcement.php` +- [X] T016 [US1] Validate `->apply()` correctly blocks unauthorized execution (via Filament's isDisabled check + defense-in-depth abort) in `app/Support/Rbac/UiEnforcement.php` +- ~~T017 [US1] Migrate TenantResource table actions to UiEnforcement~~ **OUT OF SCOPE v1**: TenantResource is record==tenant, not tenant-scoped +- [X] T018 [US1] Migrate ProviderConnectionResource actions to UiEnforcement (mixed visibility via `preserveVisibility()`) in `app/Filament/Resources/ProviderConnectionResource.php` + +**Checkpoint**: US1 complete — members see consistent disabled UX with tooltip (exemplar: ListPolicies) + +--- + +## Phase 4: User Story 2 — Non-members cannot infer tenant resources (Priority: P2) + +**Goal**: Non-members receive 404 (deny-as-not-found) for all tenant-scoped actions; actions hidden in UI + +**Independent Test**: Access tenant page as non-member → actions hidden, execution returns 404 + +### Tests for User Story 2 + +- [X] T019 [P] [US2] Test: non-member sees action hidden in UI in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php` +- [X] T020 [P] [US2] Test: non-member action is blocked (via Filament hidden-action semantics) in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php` +- [X] T021 [P] [US2] Test: membership revoked mid-session still enforces protection in `tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php` + +### Implementation for User Story 2 + +- [X] T022 [US2] Validate `->apply()` correctly sets `->hidden()` for non-members (logic in T009; this task verifies + adjusts if needed) in `app/Support/Rbac/UiEnforcement.php` +- [X] T023 [US2] Validate `->apply()` blocks non-member execution (via Filament's isHidden → isDisabled chain; 404 server-side guard is defense-in-depth) in `app/Support/Rbac/UiEnforcement.php` +- [X] T024 [US2] Migrate BackupSetResource actions (row + bulk) to UiEnforcement (mixed visibility via `preserveVisibility()`) in `app/Filament/Resources/BackupSetResource.php` +- [X] T025 [US2] Migrate PolicyResource sync actions to UiEnforcement in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php` + +**Checkpoint**: US2 complete — non-members receive 404 semantics, no information leakage + +--- + +## Phase 5: User Story 3 — Maintainers add actions safely by default (Priority: P3) + +**Goal**: CI-failing guard flags new ad-hoc authorization patterns; standard pattern documented + +**Independent Test**: Introduce ad-hoc `Gate::allows` or `abort_unless()` in Filament → guard test fails + +### Tests for User Story 3 + +- [X] T026 [P] [US3] Guard test: scan `app/Filament/**` for forbidden ad-hoc patterns (Gate + abort helpers) in `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` +- [X] T027 [P] [US3] Unit test: UiEnforcement uses only canonical Capabilities constants in `tests/Unit/Support/Rbac/UiEnforcementTest.php` + +### Implementation for User Story 3 + +- [X] T028 [US3] Replace Pest-Arch guard with stable file-scan guard (CI-failing, allowlist for legacy only) in `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php` +- [X] T029 [US3] Migrate EntraGroupResource sync actions to UiEnforcement in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` +- [X] T030 [US3] Remove Gate facade usage from FindingResource (migrate auth to canonical checks) in `app/Filament/Resources/FindingResource.php` + +**Checkpoint**: US3 complete — guardrail prevents regression (file-scan), exemplar surfaces migrated + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Cleanup, additional tests, documentation + +- [X] T031 [P] PHPDoc blocks present on all public methods in `app/Support/Rbac/UiEnforcement.php` +- [X] T032 [P] Update quickstart.md with migration examples in `specs/066-rbac-ui-enforcement-helper/quickstart.md` +- [X] T033 Run Pint formatter on new files with `vendor/bin/sail bin pint app/Support/Rbac` +- [X] T034 Run full test suite with `vendor/bin/sail artisan test --compact` — 837 passed, 5 skipped +- [X] T035 Validate quickstart.md examples work in codebase (ListPolicies migration verified) + +--- + +## Phase 7: Follow-up — Findings capability cleanup (Mini-feature) + +**Purpose**: Avoid overloading broad capabilities (e.g. `TENANT_SYNC`) for findings acknowledgement. + +- [X] T036 Add `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE` in `app/Support/Auth/Capabilities.php` +- [X] T037 Grant `TENANT_FINDINGS_ACKNOWLEDGE` to Owner/Manager/Operator (not Readonly) + update role-matrix tests +- [X] T038 Update Finding list acknowledge action to require `TENANT_FINDINGS_ACKNOWLEDGE` in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- [X] T039 Refactor `FindingPolicy::update()` to use `CapabilityResolver` with `TENANT_FINDINGS_ACKNOWLEDGE` (remove ad-hoc `Gate::forUser(...)->allows(...)`) + +--- + +## Phase 8: Follow-up — Legacy allowlist shrink (Stepwise) + +**Purpose**: Keep shrinking the Filament guard allowlist with one-file migrations. + +- [X] T040 Remove `BackupScheduleResource.php` from the legacy allowlist after migration +- [X] T041 Migrate `ListEntraGroupSyncRuns.php` to UiEnforcement + add a focused Livewire test +- [X] T042 Remove `ListEntraGroupSyncRuns.php` from the legacy allowlist after migration +- [X] T043 Migrate `ListProviderConnections.php` create action to UiEnforcement + add a focused Livewire test +- [X] T044 Remove `ListProviderConnections.php` from the legacy allowlist after migration +- [X] T045 Migrate `DriftLanding.php` generation permission check to `CapabilityResolver` (remove Gate facade) + add a focused Livewire test +- [X] T046 Remove `DriftLanding.php` from the legacy allowlist after migration +- [X] T047 Migrate `RegisterTenant.php` page-level checks to `CapabilityResolver` + replace `abort_unless()` with `abort()` +- [X] T048 Remove `RegisterTenant.php` from the legacy allowlist after migration +- [X] T049 Migrate `EditProviderConnection.php` actions + save guards to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test +- [X] T050 Remove `EditProviderConnection.php` from the legacy allowlist after migration +- [X] T051 Migrate `CreateRestoreRun.php` page authorization to `CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test +- [X] T052 Remove `CreateRestoreRun.php` from the legacy allowlist after migration +- [X] T053 Migrate `InventoryItemResource.php` resource authorization to `CapabilityResolver` (remove Gate facade) + add a focused Pest test +- [X] T054 Remove `InventoryItemResource.php` from the legacy allowlist after migration +- [X] T055 Migrate `VersionsRelationManager.php` restore action to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) + add a focused Livewire test +- [X] T056 Remove `VersionsRelationManager.php` from the legacy allowlist after migration +- [X] T057 Migrate `BackupItemsRelationManager.php` actions to `UiEnforcement` (remove Gate facade) + add a focused Livewire test +- [X] T058 Remove `BackupItemsRelationManager.php` from the legacy allowlist after migration +- [X] T059 Migrate `PolicyVersionResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) while preserving metadata-only restore behavior +- [X] T060 Remove `PolicyVersionResource.php` from the legacy allowlist after migration +- [X] T061 Migrate `RestoreRunResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement`/`CapabilityResolver` (remove Gate facade + abort_unless) +- [X] T062 Remove `RestoreRunResource.php` from the legacy allowlist after migration +- [X] T063 Fix `UiEnforcement` server-side guard to use Filament lifecycle hooks (`->before()`) to preserve Filament action parameter injection +- [X] T064 Migrate `PolicyResource.php` actions/bulk actions off ad-hoc patterns to `UiEnforcement` (remove Gate facade + abort_unless) +- [X] T065 Remove `PolicyResource.php` from the legacy allowlist after migration +- [X] T066 Migrate `EditTenant.php` archive action off ad-hoc patterns to `UiEnforcement` (remove Gate facade + abort_unless) +- [X] T067 Remove `EditTenant.php` from the legacy allowlist after migration +- [X] T068 Migrate `TenantMembershipsRelationManager.php` actions off ad-hoc patterns to `UiEnforcement` (remove Gate facade) +- [X] T069 Remove `TenantMembershipsRelationManager.php` from the legacy allowlist after migration +- [X] T070 Migrate `TenantResource.php` off ad-hoc patterns to `CapabilityResolver` (remove Gate facade + abort_unless) +- [X] T071 Remove `TenantResource.php` from the legacy allowlist after migration + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **User Stories (Phase 3–5)**: All depend on Foundational; can proceed in parallel or by priority +- **Polish (Phase 6)**: Depends on all user stories + +### User Story Dependencies + +- **US1 (P1)**: Foundational only — no cross-story dependencies +- **US2 (P2)**: Foundational only — no cross-story dependencies +- **US3 (P3)**: Foundational only — no cross-story dependencies + +### Within Each User Story + +- Tests MUST be written FIRST and FAIL before implementation +- Wire logic in `UiEnforcement.php` before migrating Filament surfaces +- Migrate surfaces one at a time, verify tests pass + +### Parallel Opportunities + +- T002 + T003 (Setup) can run in parallel +- All test tasks (T012–T014, T019–T021, T026–T027) can run in parallel +- US1, US2, US3 can run in parallel after Foundational +- T031 + T032 (Polish) can run in parallel + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for US1 together: +T012: "Test: member without capability sees disabled action + tooltip" +T013: "Test: member without capability receives 403 on execution" +T014: "Test: member with capability sees enabled action + can execute" + +# Then implement sequentially: +T015 → T016 → T017 → T018 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001–T003) +2. Complete Phase 2: Foundational (T004–T011) +3. Complete Phase 3: User Story 1 (T012–T018) +4. **STOP and VALIDATE**: Members see disabled + tooltip + 403 +5. Deploy/demo if ready + +### Incremental Delivery + +1. Setup + Foundational → `UiEnforcement` ready +2. US1 → Consistent disabled UX for members (MVP!) +3. US2 → Non-member 404 enforcement +4. US3 → CI-failing guardrail + all 6 surfaces migrated +5. Polish → Docs, cleanup, full test suite + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| Total tasks | 40 | +| Setup tasks | 3 | +| Foundational tasks | 8 | +| US1 tasks | 8 | +| US2 tasks | 7 | +| US3 tasks | 5 | +| Polish tasks | 5 | +| Follow-up tasks | 4 | +| Parallel opportunities | 13 | +| MVP scope | Phases 1–3 (T001–T018) |