066-rbac-ui-enforcement-helper #81

Merged
ahmido merged 6 commits from 066-rbac-ui-enforcement-helper into dev 2026-01-30 16:58:03 +00:00
3 changed files with 704 additions and 0 deletions
Showing only changes of commit cb932e380b - Show all commits

View File

@ -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 36 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/... # 36 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 + 36 migrations).
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |

View File

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

View File

@ -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 35)**: 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 (T012T014, T019T021, T026T027) 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 (T001T003)
2. Complete Phase 2: Foundational (T004T011)
3. Complete Phase 3: User Story 1 (T012T018)
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 13 (T001T018) |