066-rbac-ui-enforcement-helper #81
188
specs/066-rbac-ui-enforcement-helper/plan.md
Normal file
188
specs/066-rbac-ui-enforcement-helper/plan.md
Normal 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 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 |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
262
specs/066-rbac-ui-enforcement-helper/quickstart.md
Normal file
262
specs/066-rbac-ui-enforcement-helper/quickstart.md
Normal 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.
|
||||||
254
specs/066-rbac-ui-enforcement-helper/tasks.md
Normal file
254
specs/066-rbac-ui-enforcement-helper/tasks.md
Normal 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 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) |
|
||||||
Loading…
Reference in New Issue
Block a user