Compare commits

...

2 Commits

Author SHA1 Message Date
55166cf9b8 Spec 083: Required permissions hardening (canonical /admin/tenants, DB-only, 404 semantics) (#101)
Implements Spec 083 (Canonical Required Permissions manage surface hardening + issues-first UX).

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

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

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

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

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

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

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

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #100
2026-02-08 20:31:36 +00:00
67 changed files with 3822 additions and 166 deletions

View File

@ -21,6 +21,7 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin) - PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin) - PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -40,9 +41,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 - 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 - 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@ -1,11 +1,10 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.6.0 → 1.7.0 - Version change: 1.7.0 → 1.8.0
- Modified principles: - Modified principles:
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate) - Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule)
- Added sections: - Added sections: None
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/plan-template.md
@ -144,6 +143,9 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
Required surfaces Required surfaces
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s). - List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
- View/Detail MUST define Header Actions (Edit + “More” group when applicable). - View/Detail MUST define Header Actions (Edit + “More” group when applicable).
- Create/Edit MUST provide consistent Save/Cancel UX. - Create/Edit MUST provide consistent Save/Cancel UX.
@ -198,4 +200,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08 **Version**: 1.8.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08

View File

@ -43,7 +43,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
## Project Structure ## Project Structure

View File

@ -129,9 +129,9 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log. RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | | Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| Resource/Page/RM | e.g. app/Filament/... | | | | | | | | | | Resource/Page/RM | e.g. app/Filament/... | | e.g. `recordUrl()` / View action / linked column | | | | | | | |
### Key Entities *(include if feature involves data)* ### Key Entities *(include if feature involves data)*

View File

@ -27,6 +27,7 @@ # Tasks: [FEATURE NAME]
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include: **Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
- filling the specs “UI Action Matrix” for all changed surfaces, - filling the specs “UI Action Matrix” for all changed surfaces,
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit), - implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule, - enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
- grouping bulk actions via BulkActionGroup, - grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale), - adding confirmations for destructive actions (and typed confirmation where required by scale),

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
@ -41,34 +40,28 @@ class TenantRequiredPermissions extends Page
*/ */
public array $viewModel = []; public array $viewModel = [];
public ?Tenant $scopedTenant = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$tenant = static::resolveScopedTenant(); return static::hasScopedTenantAccess(static::resolveScopedTenant());
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
} }
public function currentTenant(): ?Tenant public function currentTenant(): ?Tenant
{ {
return static::resolveScopedTenant(); return $this->scopedTenant;
} }
public function mount(): void public function mount(): void
{ {
$tenant = static::resolveScopedTenant();
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404);
}
$this->scopedTenant = $tenant;
$queryFeatures = request()->query('features', $this->features); $queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ $state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
@ -147,7 +140,7 @@ public function resetFilters(): void
private function refreshViewModel(): void private function refreshViewModel(): void
{ {
$tenant = static::resolveScopedTenant(); $tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$this->viewModel = []; $this->viewModel = [];
@ -174,25 +167,20 @@ private function refreshViewModel(): void
} }
} }
public function reRunVerificationUrl(): ?string public function reRunVerificationUrl(): string
{ {
$tenant = static::resolveScopedTenant(); return route('admin.onboarding');
}
public function manageProviderConnectionUrl(): ?string
{
$tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return null; return null;
} }
$connectionId = ProviderConnection::query() return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('is_default')
->orderByDesc('id')
->value('id');
if (! is_int($connectionId)) {
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
return ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connectionId], panel: 'admin');
} }
protected static function resolveScopedTenant(): ?Tenant protected static function resolveScopedTenant(): ?Tenant
@ -209,6 +197,32 @@ protected static function resolveScopedTenant(): ?Tenant
->first(); ->first();
} }
return Tenant::current(); return null;
}
private static function hasScopedTenantAccess(?Tenant $tenant): bool
{
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
$isWorkspaceMember = WorkspaceMembership::query()
->where('workspace_id', (int) $workspaceId)
->where('user_id', (int) $user->getKey())
->exists();
if (! $isWorkspaceMember) {
return false;
}
return $user->canAccessTenant($tenant);
} }
} }

View File

@ -7,8 +7,11 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -32,6 +35,16 @@ class EntraGroupResource extends Resource
protected static ?string $navigationLabel = 'Groups'; protected static ?string $navigationLabel = 'Groups';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -88,15 +101,25 @@ public static function table(Table $table): Table
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
}) })
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->columns([ ->columns([
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), Tables\Columns\TextColumn::make('display_name')
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(), ->label('Name')
->searchable(),
Tables\Columns\TextColumn::make('entra_id')
->label('Entra ID')
->copyable()
->toggleable(),
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Type') ->label('Type')
->badge() ->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))) ->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))), ->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'), Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
]) ])
->filters([ ->filters([
SelectFilter::make('stale') SelectFilter::make('stale')
@ -165,9 +188,7 @@ public static function table(Table $table): Table
}; };
}), }),
]) ])
->actions([ ->actions([])
Actions\ViewAction::make(),
])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -8,8 +8,11 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -34,6 +37,16 @@ class EntraGroupSyncRunResource extends Resource
protected static ?string $navigationLabel = 'Group Sync Runs'; protected static ?string $navigationLabel = 'Group Sync Runs';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -131,9 +144,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(), Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(), Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
]) ])
->actions([ ->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
Actions\ViewAction::make(), ? static::getUrl('view', ['record' => $record])
]) : null)
->actions([])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -17,8 +17,11 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType; use App\Support\Enums\RelationshipType;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -42,6 +45,16 @@ class InventoryItemResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory items list intentionally has no header actions; items are populated via sync.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for inventory items.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; inventory items are created by inventory sync.');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -262,9 +275,10 @@ public static function table(Table $table): Table
->options($categoryOptions) ->options($categoryOptions)
->searchable(), ->searchable(),
]) ])
->actions([ ->recordUrl(static fn (Model $record): ?string => static::canView($record)
Actions\ViewAction::make(), ? static::getUrl('view', ['record' => $record])
]) : null)
->actions([])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -12,8 +12,11 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -39,6 +42,16 @@ class InventorySyncRunResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -189,9 +202,10 @@ public static function table(Table $table): Table
->label('Errors') ->label('Errors')
->numeric(), ->numeric(),
]) ])
->actions([ ->recordUrl(static fn (Model $record): ?string => static::canView($record)
Actions\ViewAction::make(), ? static::getUrl('view', ['record' => $record])
]) : null)
->actions([])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -16,6 +16,11 @@
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -41,12 +46,40 @@ class OperationRunResource extends Resource
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations'; protected static ?string $navigationLabel = 'Operations';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->exempt(
ActionSurfaceSlot::ListHeader,
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(
ActionSurfaceSlot::ListBulkMoreGroup,
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
)
->exempt(
ActionSurfaceSlot::ListEmptyState,
'Empty-state action is intentionally omitted; users can adjust filters/date range in-page.',
)
->exempt(
ActionSurfaceSlot::DetailHeader,
'Tenantless detail view is informational and currently has no header actions.',
);
}
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();

View File

@ -23,6 +23,10 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -52,6 +56,17 @@ class PolicyResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -536,7 +551,9 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility() ->preserveVisibility()
->apply(), ->apply(),
])->icon('heroicon-o-ellipsis-vertical'), ])
->label('More')
->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
@ -888,7 +905,7 @@ public static function table(Table $table): Table
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
]), ])->label('More'),
]); ]);
} }

View File

@ -22,71 +22,79 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [$this->makeSyncAction()];
UiEnforcement::forAction( }
Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant) { protected function getTableEmptyStateActions(): array
abort(404); {
} return [$this->makeSyncAction()];
}
$requestedTypes = array_map( private function makeSyncAction(): Actions\Action
static fn (array $typeConfig): string => (string) $typeConfig['type'], {
config('tenantpilot.supported_policy_types', []) return UiEnforcement::forAction(
); Actions\Action::make('sync')
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
sort($requestedTypes); if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
/** @var OperationRunService $opService */ $requestedTypes = array_map(
$opService = app(OperationRunService::class); static fn (array $typeConfig): string => (string) $typeConfig['type'],
$opRun = $opService->ensureRun( config('tenantpilot.supported_policy_types', [])
tenant: $tenant, );
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { sort($requestedTypes);
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return; /** @var OperationRunService $opService */
} $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun); Notification::make()
}); ->title('Policy sync already active')
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); ->body('This operation is already queued or running.')
OperationUxPresenter::queuedToast((string) $opRun->type) ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('View run') ->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)), ->url(OperationRunLinks::view($opRun, $tenant)),
]) ])
->send(); ->send();
})
) return;
->requireCapability(Capabilities::TENANT_SYNC) }
->tooltip('You do not have permission to sync policies.')
->destructive() $opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
->apply(), SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
]; });
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->destructive()
->apply();
} }
} }

View File

@ -13,6 +13,10 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -24,6 +28,16 @@ class VersionsRelationManager extends RelationManager
{ {
protected static string $relationship = 'versions'; protected static string $relationship = 'versions';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
}
public function table(Table $table): Table public function table(Table $table): Table
{ {
$restoreToIntune = Actions\Action::make('restore_to_intune') $restoreToIntune = Actions\Action::make('restore_to_intune')

View File

@ -23,6 +23,10 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Filament\Actions; use Filament\Actions;
@ -51,6 +55,17 @@ class PolicyVersionResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->exempt(ActionSurfaceSlot::ListHeader, 'Policy versions list intentionally has no header actions; versions are created by sync and captures.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; versions appear after policy sync/capture workflows.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.');
}
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
@ -494,8 +509,10 @@ public static function table(Table $table): Table
->trueLabel('All') ->trueLabel('All')
->falseLabel('Archived'), ->falseLabel('Archived'),
]) ])
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([ ->actions([
Actions\ViewAction::make(),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
(function (): Actions\Action { (function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard') $action = Actions\Action::make('restore_via_wizard')

View File

@ -5,6 +5,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPermission; use App\Models\TenantPermission;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use DateTimeInterface;
use Illuminate\Support\Carbon;
class TenantPermissionService class TenantPermissionService
{ {
@ -44,6 +46,7 @@ public function getGrantedPermissions(Tenant $tenant): array
* @return array{ * @return array{
* overall_status:string, * overall_status:string,
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>, * permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
* last_refreshed_at:?string,
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string} * live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
* } * }
*/ */
@ -210,6 +213,7 @@ public function compare(
$payload = [ $payload = [
'overall_status' => $overall, 'overall_status' => $overall,
'permissions' => $results, 'permissions' => $results,
'last_refreshed_at' => $this->lastRefreshedAtIso($tenant),
]; ];
if ($liveCheckMeta['attempted'] === true) { if ($liveCheckMeta['attempted'] === true) {
@ -389,4 +393,25 @@ private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = nul
]; ];
} }
} }
private function lastRefreshedAtIso(Tenant $tenant): ?string
{
$lastCheckedAt = TenantPermission::query()
->where('tenant_id', (int) $tenant->getKey())
->max('last_checked_at');
if ($lastCheckedAt instanceof DateTimeInterface) {
return Carbon::instance($lastCheckedAt)->toIso8601String();
}
if (is_string($lastCheckedAt) && $lastCheckedAt !== '') {
try {
return Carbon::parse($lastCheckedAt)->toIso8601String();
} catch (\Throwable) {
return null;
}
}
return null;
}
} }

View File

@ -4,6 +4,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Verification\VerificationReportOverall; use App\Support\Verification\VerificationReportOverall;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
class TenantRequiredPermissionsViewModelBuilder class TenantRequiredPermissionsViewModelBuilder
{ {
@ -16,7 +18,8 @@ class TenantRequiredPermissionsViewModelBuilder
* overview: array{ * overview: array{
* overall: string, * overall: string,
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int}, * counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
* feature_impacts: array<int, FeatureImpact> * feature_impacts: array<int, FeatureImpact>,
* freshness: array{last_refreshed_at:?string,is_stale:bool}
* }, * },
* permissions: array<int, TenantPermissionRow>, * permissions: array<int, TenantPermissionRow>,
* filters: FilterState, * filters: FilterState,
@ -48,6 +51,7 @@ public function build(Tenant $tenant, array $filters = []): array
$state = self::normalizeFilterState($filters); $state = self::normalizeFilterState($filters);
$filteredPermissions = self::applyFilterState($allPermissions, $state); $filteredPermissions = self::applyFilterState($allPermissions, $state);
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
return [ return [
'tenant' => [ 'tenant' => [
@ -56,9 +60,10 @@ public function build(Tenant $tenant, array $filters = []): array
'name' => (string) $tenant->name, 'name' => (string) $tenant->name,
], ],
'overview' => [ 'overview' => [
'overall' => self::deriveOverallStatus($allPermissions), 'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($allPermissions), 'counts' => self::deriveCounts($allPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions), 'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
'freshness' => $freshness,
], ],
'permissions' => $filteredPermissions, 'permissions' => $filteredPermissions,
'filters' => $state, 'filters' => $state,
@ -72,7 +77,7 @@ public function build(Tenant $tenant, array $filters = []): array
/** /**
* @param array<int, TenantPermissionRow> $permissions * @param array<int, TenantPermissionRow> $permissions
*/ */
public static function deriveOverallStatus(array $permissions): string public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string
{ {
$hasMissingApplication = collect($permissions)->contains( $hasMissingApplication = collect($permissions)->contains(
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application', fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
@ -90,13 +95,35 @@ public static function deriveOverallStatus(array $permissions): string
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated', fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
); );
if ($hasErrors || $hasMissingDelegated) { if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) {
return VerificationReportOverall::NeedsAttention->value; return VerificationReportOverall::NeedsAttention->value;
} }
return VerificationReportOverall::Ready->value; return VerificationReportOverall::Ready->value;
} }
/**
* @return array{last_refreshed_at:?string,is_stale:bool}
*/
public static function deriveFreshness(?CarbonInterface $lastRefreshedAt, ?CarbonInterface $referenceTime = null): array
{
$reference = $referenceTime instanceof Carbon
? $referenceTime->copy()
: ($referenceTime !== null ? Carbon::instance($referenceTime) : now());
$lastRefreshed = $lastRefreshedAt instanceof Carbon
? $lastRefreshedAt
: ($lastRefreshedAt !== null ? Carbon::instance($lastRefreshedAt) : null);
$isStale = $lastRefreshed === null
|| $lastRefreshed->lt($reference->copy()->subDays(30));
return [
'last_refreshed_at' => $lastRefreshed?->toIso8601String(),
'is_stale' => $isStale,
];
}
/** /**
* @param array<int, TenantPermissionRow> $permissions * @param array<int, TenantPermissionRow> $permissions
* @return array{missing_application:int,missing_delegated:int,present:int,error:int} * @return array{missing_application:int,missing_delegated:int,present:int,error:int}
@ -386,4 +413,25 @@ private static function normalizePermissionRow(array $row): array
'details' => $details, 'details' => $details,
]; ];
} }
private static function parseLastRefreshedAt(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
if ($value instanceof CarbonInterface) {
return Carbon::instance($value);
}
if (is_string($value) && $value !== '') {
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
return null;
}
} }

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceDeclaration
{
/**
* @var array<string, ActionSurfaceSlotRequirement>
*/
private array $slots = [];
/**
* @var array<string, ActionSurfaceExemption>
*/
private array $exemptions = [];
public ActionSurfaceDefaults $defaults;
public function __construct(
public readonly int $version,
public readonly ActionSurfaceComponentType $componentType,
public readonly ActionSurfaceProfile $profile,
?ActionSurfaceDefaults $defaults = null,
) {
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
}
public static function make(
ActionSurfaceComponentType $componentType,
ActionSurfaceProfile $profile,
int $version = 1,
): self {
return new self(
version: $version,
componentType: $componentType,
profile: $profile,
);
}
public static function forResource(ActionSurfaceProfile $profile, int $version = 1): self
{
return self::make(ActionSurfaceComponentType::Resource, $profile, $version);
}
public static function forPage(ActionSurfaceProfile $profile, int $version = 1): self
{
return self::make(ActionSurfaceComponentType::Page, $profile, $version);
}
public static function forRelationManager(ActionSurfaceProfile $profile, int $version = 1): self
{
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $version);
}
public function withDefaults(ActionSurfaceDefaults $defaults): self
{
$this->defaults = $defaults;
return $this;
}
public function setSlot(ActionSurfaceSlot $slot, ActionSurfaceSlotRequirement $requirement): self
{
$this->slots[$slot->value] = $requirement;
return $this;
}
public function satisfy(
ActionSurfaceSlot $slot,
?string $details = null,
bool $requiresTypedConfirmation = false,
): self {
return $this->setSlot($slot, ActionSurfaceSlotRequirement::satisfied($details, $requiresTypedConfirmation));
}
public function exempt(
ActionSurfaceSlot $slot,
string $reason,
?string $trackingRef = null,
?string $details = null,
): self {
$this->setSlot($slot, ActionSurfaceSlotRequirement::exempt($details));
$this->exemptions[$slot->value] = new ActionSurfaceExemption($slot, $reason, $trackingRef);
return $this;
}
public function slot(ActionSurfaceSlot $slot): ?ActionSurfaceSlotRequirement
{
return $this->slots[$slot->value] ?? null;
}
public function exemption(ActionSurfaceSlot $slot): ?ActionSurfaceExemption
{
return $this->exemptions[$slot->value] ?? null;
}
/**
* @return array<string, ActionSurfaceSlotRequirement>
*/
public function slots(): array
{
return $this->slots;
}
/**
* @return array<string, ActionSurfaceExemption>
*/
public function exemptions(): array
{
return $this->exemptions;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
final class ActionSurfaceDefaults
{
public function __construct(
public readonly string $moreGroupLabel = 'More',
public readonly bool $exportIsDefaultBulkActionForReadOnly = true,
) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
final class ActionSurfaceDiscoveredComponent
{
/**
* @param array<int, ActionSurfacePanelScope> $panelScopes
*/
public function __construct(
public readonly string $className,
public readonly ActionSurfaceComponentType $componentType,
public readonly array $panelScopes,
) {}
public function hasPanelScope(ActionSurfacePanelScope $scope): bool
{
return in_array($scope, $this->panelScopes, true);
}
}

View File

@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class ActionSurfaceDiscovery
{
private string $basePath;
private string $appPath;
private string $routesPath;
private string $adminPanelProviderPath;
public function __construct(
?string $basePath = null,
?string $appPath = null,
?string $routesPath = null,
?string $adminPanelProviderPath = null,
) {
$this->basePath = $basePath ?? base_path();
$this->appPath = $appPath ?? app_path();
$this->routesPath = $routesPath ?? base_path('routes/web.php');
$this->adminPanelProviderPath = $adminPanelProviderPath ?? app_path('Providers/Filament/AdminPanelProvider.php');
}
/**
* @return array<int, ActionSurfaceDiscoveredComponent>
*/
public function discover(): array
{
$adminScopedClasses = $this->discoverAdminScopedClasses();
/** @var array<string, ActionSurfaceDiscoveredComponent> $components */
$components = [];
foreach ($this->resourceFiles() as $path) {
$className = $this->classNameFromPath($path);
$components[$className] = new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Resource,
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
);
}
foreach ($this->pageFiles() as $path) {
$className = $this->classNameFromPath($path);
$components[$className] = new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Page,
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
);
}
foreach ($this->relationManagerFiles() as $path) {
$className = $this->classNameFromPath($path);
$components[$className] = new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::RelationManager,
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
);
}
ksort($components);
return array_values($components);
}
/**
* @param array<int, string> $adminScopedClasses
* @return array<int, ActionSurfacePanelScope>
*/
private function panelScopesFor(string $className, array $adminScopedClasses): array
{
$scopes = [ActionSurfacePanelScope::Tenant];
if (in_array($className, $adminScopedClasses, true)) {
$scopes[] = ActionSurfacePanelScope::Admin;
}
return $scopes;
}
/**
* @return array<int, string>
*/
private function resourceFiles(): array
{
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
if (! str_ends_with($path, 'Resource.php')) {
return false;
}
if (str_contains($path, '/Pages/')) {
return false;
}
if (str_contains($path, '/RelationManagers/')) {
return false;
}
return true;
});
}
/**
* @return array<int, string>
*/
private function pageFiles(): array
{
return $this->collectPhpFiles($this->appPath.'/Filament/Pages', static function (string $path): bool {
return str_ends_with($path, '.php');
});
}
/**
* @return array<int, string>
*/
private function relationManagerFiles(): array
{
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
if (! str_contains($path, '/RelationManagers/')) {
return false;
}
return str_ends_with($path, 'RelationManager.php');
});
}
/**
* @param callable(string): bool $filter
* @return array<int, string>
*/
private function collectPhpFiles(string $directory, callable $filter): array
{
if (! is_dir($directory)) {
return [];
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
);
$paths = [];
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = str_replace('\\', '/', $file->getPathname());
if (! $filter($path)) {
continue;
}
$paths[] = $path;
}
sort($paths);
return $paths;
}
/**
* @return array<int, string>
*/
private function discoverAdminScopedClasses(): array
{
$classes = array_merge(
$this->parseFilamentClassReferences($this->adminPanelProviderPath),
$this->parseFilamentClassReferences($this->routesPath),
);
$classes = array_values(array_unique(array_filter($classes, static function (string $className): bool {
return str_starts_with($className, 'App\\Filament\\');
})));
sort($classes);
return $classes;
}
/**
* @return array<int, string>
*/
private function parseFilamentClassReferences(string $filePath): array
{
if (! is_file($filePath)) {
return [];
}
$contents = file_get_contents($filePath);
if (! is_string($contents) || $contents === '') {
return [];
}
$imports = $this->parseUseStatements($contents);
preg_match_all('/\\\\?([A-Z][A-Za-z0-9_\\\\]*)::(?:class|registerRoutes)\b/', $contents, $matches);
$classes = [];
foreach ($matches[1] as $token) {
$resolved = $this->resolveClassToken($token, $imports);
if ($resolved === null) {
continue;
}
$classes[] = $resolved;
}
return $classes;
}
/**
* @return array<string, string>
*/
private function parseUseStatements(string $contents): array
{
preg_match_all('/^use\s+([^;]+);/m', $contents, $matches);
$imports = [];
foreach ($matches[1] as $importExpression) {
$normalized = trim($importExpression);
if (! str_contains($normalized, '\\')) {
continue;
}
$parts = preg_split('/\s+as\s+/i', $normalized);
$fqcn = ltrim($parts[0], '\\');
$alias = $parts[1] ?? null;
if (! is_string($alias) || trim($alias) === '') {
$segments = explode('\\', $fqcn);
$alias = end($segments);
}
if (! is_string($alias) || trim($alias) === '') {
continue;
}
$imports[trim($alias)] = $fqcn;
}
return $imports;
}
/**
* @param array<string, string> $imports
*/
private function resolveClassToken(string $token, array $imports): ?string
{
$token = ltrim(trim($token), '\\');
if ($token === '') {
return null;
}
if (str_contains($token, '\\')) {
return $token;
}
return $imports[$token] ?? null;
}
private function classNameFromPath(string $path): string
{
$normalizedPath = str_replace('\\', '/', $path);
$normalizedAppPath = str_replace('\\', '/', $this->appPath);
$relative = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
return 'App\\'.str_replace('/', '\\', substr($relative, 0, -4));
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceExemption
{
public function __construct(
public readonly ActionSurfaceSlot $slot,
public readonly string $reason,
public readonly ?string $trackingRef = null,
) {}
public function hasReason(): bool
{
return trim($this->reason) !== '';
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
final class ActionSurfaceExemptions
{
/**
* @param array<string, string> $componentReasons
*/
public function __construct(
private readonly array $componentReasons,
) {}
public static function baseline(): self
{
return new self([
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\DriftLanding' => 'Drift landing retrofit deferred to drift-focused UI spec.',
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\InventoryLanding' => 'Inventory landing page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.',
'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.',
'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.',
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
'App\\Filament\\Pages\\TenantDiagnostics' => 'Diagnostics page retrofit deferred to tenant-RBAC diagnostics spec.',
'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.',
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
'App\\Filament\\Resources\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.',
'App\\Filament\\Resources\\BackupScheduleResource\\RelationManagers\\BackupScheduleRunsRelationManager' => 'Backup schedule runs relation manager retrofit deferred to backup scheduling track.',
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.',
'App\\Filament\\Resources\\ProviderConnectionResource' => 'Provider connection resource retrofit deferred to provider-connection track.',
'App\\Filament\\Resources\\RestoreRunResource' => 'Restore run resource retrofit deferred to restore track.',
'App\\Filament\\Resources\\TenantResource' => 'Tenant resource retrofit deferred to tenant administration track.',
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.',
'App\\Filament\\Resources\\Workspaces\\WorkspaceResource' => 'Workspace resource retrofit deferred to workspace management track.',
]);
}
/**
* @return array<string, string>
*/
public function all(): array
{
return $this->componentReasons;
}
public function reasonForClass(string $className): ?string
{
return $this->componentReasons[$className] ?? null;
}
public function hasClass(string $className): bool
{
return array_key_exists($className, $this->componentReasons);
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceProfileDefinition
{
/**
* @return array<int, ActionSurfaceSlot>
*/
public function requiredSlots(ActionSurfaceProfile $profile): array
{
return match ($profile) {
ActionSurfaceProfile::CrudListAndEdit,
ActionSurfaceProfile::CrudListAndView => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListRowMoreMenu,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
ActionSurfaceSlot::DetailHeader,
],
ActionSurfaceProfile::ListOnlyReadOnly => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListRowMoreMenu,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
],
ActionSurfaceProfile::RunLog => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
ActionSurfaceSlot::DetailHeader,
],
ActionSurfaceProfile::RelationManager => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListRowMoreMenu,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
],
};
}
public function requiresExportDefaultBulk(ActionSurfaceProfile $profile): bool
{
return in_array($profile, [
ActionSurfaceProfile::ListOnlyReadOnly,
ActionSurfaceProfile::RunLog,
], true);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlotStatus;
final class ActionSurfaceSlotRequirement
{
public function __construct(
public readonly ActionSurfaceSlotStatus $status,
public readonly ?string $details = null,
public readonly bool $requiresTypedConfirmation = false,
) {}
public static function satisfied(?string $details = null, bool $requiresTypedConfirmation = false): self
{
return new self(
status: ActionSurfaceSlotStatus::Satisfied,
details: $details,
requiresTypedConfirmation: $requiresTypedConfirmation,
);
}
public static function exempt(?string $details = null): self
{
return new self(
status: ActionSurfaceSlotStatus::Exempt,
details: $details,
);
}
public function isExempt(): bool
{
return $this->status === ActionSurfaceSlotStatus::Exempt;
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidationIssue
{
public function __construct(
public readonly string $className,
public readonly string $message,
public readonly ?ActionSurfaceSlot $slot = null,
public readonly ?string $hint = null,
) {}
public function format(): string
{
$line = $this->className;
if ($this->slot !== null) {
$line .= ' ['.$this->slot->value.']';
}
$line .= ': '.$this->message;
if ($this->hint !== null && trim($this->hint) !== '') {
$line .= ' (hint: '.$this->hint.')';
}
return $line;
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
final class ActionSurfaceValidationResult
{
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
public function __construct(
private readonly array $issues,
private readonly int $componentCount,
) {}
public function hasIssues(): bool
{
return $this->issues !== [];
}
/**
* @return array<int, ActionSurfaceValidationIssue>
*/
public function issues(): array
{
return $this->issues;
}
public function componentCount(): int
{
return $this->componentCount;
}
public function formatForAssertion(): string
{
if (! $this->hasIssues()) {
return sprintf('Validated %d action-surface components with no issues.', $this->componentCount);
}
$lines = array_map(
static fn (ActionSurfaceValidationIssue $issue): string => '- '.$issue->format(),
$this->issues,
);
return sprintf(
"Action Surface Contract violations (%d/%d):\n%s",
count($this->issues),
$this->componentCount,
implode("\n", $lines),
);
}
}

View File

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidator
{
private ActionSurfaceDiscovery $discovery;
private ActionSurfaceProfileDefinition $profileDefinition;
private ActionSurfaceExemptions $exemptions;
public function __construct(
?ActionSurfaceDiscovery $discovery = null,
?ActionSurfaceProfileDefinition $profileDefinition = null,
?ActionSurfaceExemptions $exemptions = null,
) {
$this->discovery = $discovery ?? new ActionSurfaceDiscovery;
$this->profileDefinition = $profileDefinition ?? new ActionSurfaceProfileDefinition;
$this->exemptions = $exemptions ?? ActionSurfaceExemptions::baseline();
}
/**
* @return array<int, ActionSurfaceDiscoveredComponent>
*/
public function discoveredComponents(): array
{
return $this->discovery->discover();
}
public static function withBaselineExemptions(): self
{
return new self(
discovery: new ActionSurfaceDiscovery,
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: ActionSurfaceExemptions::baseline(),
);
}
public function validate(): ActionSurfaceValidationResult
{
return $this->validateComponents($this->discoveredComponents());
}
/**
* @param array<int, ActionSurfaceDiscoveredComponent> $components
*/
public function validateComponents(array $components): ActionSurfaceValidationResult
{
$issues = [];
foreach ($components as $component) {
if (! class_exists($component->className)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: 'Discovered class does not exist or is not autoloadable.',
hint: 'Verify namespace/path and run composer dump-autoload if needed.',
);
continue;
}
$declaration = $this->resolveDeclarationForComponent($component, $issues);
if ($declaration === null) {
continue;
}
if ($declaration->componentType !== $component->componentType) {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: sprintf(
'Declaration component type mismatch (%s declared, %s discovered).',
$declaration->componentType->name,
$component->componentType->name,
),
hint: 'Use ActionSurfaceDeclaration::forResource/forPage/forRelationManager consistently.',
);
}
if ($declaration->defaults->moreGroupLabel !== 'More') {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: sprintf(
'Invalid more-group label "%s".',
$declaration->defaults->moreGroupLabel,
),
hint: 'Set ActionSurfaceDefaults->moreGroupLabel to "More".',
);
}
$this->validateRequiredSlots($component->className, $declaration, $issues);
$this->validateExemptions($component->className, $declaration, $issues);
$this->validateExportDefaults($component->className, $declaration, $issues);
}
return new ActionSurfaceValidationResult(
issues: $issues,
componentCount: count($components),
);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function resolveDeclarationForComponent(
ActionSurfaceDiscoveredComponent $component,
array &$issues,
): ?ActionSurfaceDeclaration {
$className = $component->className;
if (! method_exists($className, 'actionSurfaceDeclaration')) {
$this->validateClassExemptionOrFail($className, $issues);
return null;
}
try {
$declaration = $className::actionSurfaceDeclaration();
} catch (\Throwable $throwable) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'actionSurfaceDeclaration() threw an exception: '.$throwable->getMessage(),
hint: 'Ensure actionSurfaceDeclaration() is static and does not depend on request state.',
);
return null;
}
if (! $declaration instanceof ActionSurfaceDeclaration) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'actionSurfaceDeclaration() must return ActionSurfaceDeclaration.',
hint: 'Return ActionSurfaceDeclaration::forResource/forPage/forRelationManager(...).',
);
return null;
}
return $declaration;
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateClassExemptionOrFail(string $className, array &$issues): void
{
$reason = $this->exemptions->reasonForClass($className);
if ($reason === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Missing action-surface declaration and no component exemption exists.',
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.',
);
return;
}
if (trim($reason) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Component exemption reason must be non-empty.',
hint: 'Provide a concrete, non-empty justification in ActionSurfaceExemptions::baseline().',
);
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateRequiredSlots(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
foreach ($this->profileDefinition->requiredSlots($declaration->profile) as $slot) {
$requirement = $declaration->slot($slot);
if ($requirement === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: $slot,
message: 'Required slot is not declared.',
hint: 'Declare slot as satisfied or exempt with a reason.',
);
continue;
}
if (! $requirement->isExempt()) {
if ($slot === ActionSurfaceSlot::InspectAffordance) {
$this->validateInspectAffordanceSlot($className, $requirement, $issues);
}
continue;
}
$exemption = $declaration->exemption($slot);
if ($exemption === null || ! $exemption->hasReason()) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: $slot,
message: 'Slot is marked exempt but exemption reason is missing or empty.',
hint: 'Use ->exempt(slot, "reason") with a non-empty reason.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateInspectAffordanceSlot(
string $className,
ActionSurfaceSlotRequirement $requirement,
array &$issues,
): void {
$mode = $requirement->details;
if (! is_string($mode) || trim($mode) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: 'Inspect affordance must declare how inspection is provided (clickable_row, view_action, or primary_link_column).',
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
);
return;
}
if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) {
return;
}
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateExemptions(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
foreach ($declaration->exemptions() as $slotValue => $exemption) {
if (! $exemption->hasReason()) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::from($slotValue),
message: 'Exemption reason must be non-empty.',
hint: 'Provide a concise reason for each exempted slot.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateExportDefaults(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
if (! $this->profileDefinition->requiresExportDefaultBulk($declaration->profile)) {
return;
}
if ($declaration->defaults->exportIsDefaultBulkActionForReadOnly) {
return;
}
$bulkExemption = $declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup);
if ($bulkExemption instanceof ActionSurfaceExemption && $bulkExemption->hasReason()) {
return;
}
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::ListBulkMoreGroup,
message: 'ReadOnly/RunLog profile disables Export default but no bulk-slot exemption reason was provided.',
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceComponentType: string
{
case Resource = 'resource';
case Page = 'page';
case RelationManager = 'relation_manager';
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceInspectAffordance: string
{
case ClickableRow = 'clickable_row';
case ViewAction = 'view_action';
case PrimaryLinkColumn = 'primary_link_column';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfacePanelScope: string
{
case Tenant = 'tenant';
case Admin = 'admin';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceProfile: string
{
case CrudListAndEdit = 'crud_list_and_edit';
case CrudListAndView = 'crud_list_and_view';
case ListOnlyReadOnly = 'list_only_read_only';
case RunLog = 'run_log';
case RelationManager = 'relation_manager';
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceSlot: string
{
case ListHeader = 'ListHeader';
case InspectAffordance = 'InspectAffordance';
case ListRowMoreMenu = 'ListRowMoreMenu';
case ListBulkMoreGroup = 'ListBulkMoreGroup';
case ListEmptyState = 'ListEmptyState';
case DetailHeader = 'DetailHeader';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceSlotStatus: string
{
case Satisfied = 'Satisfied';
case Exempt = 'Exempt';
}

View File

@ -0,0 +1,26 @@
# Action surface contract
This project enforces a small “action surface contract” for Filament Resources / Pages / RelationManagers to keep table UIs consistent, quiet, and safe.
## Inspect affordance (required)
Any list-style surface that exposes records must provide an **inspect affordance** so an admin can open a record.
Accepted implementations:
- **Clickable rows** (preferred): set `recordUrl()` for the table.
- **View action**: a `ViewAction` in the row actions.
- **Primary link column**: a column that is clearly the primary affordance to open the record.
### Rule: no lone “View” button
Avoid rendering a table that only has a single `View` row action. This creates visual noise and adds an unnecessary Actions column.
Preferred approach:
- Make the row clickable via `recordUrl()` and set `actions([])` so no Actions column is rendered.
## RBAC / safety
- If the current user cannot inspect a record, `recordUrl()` must return `null` for that record.
- UI visibility is not authorization; always enforce permissions at the policy / resource level.

View File

@ -2,6 +2,7 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Links\RequiredPermissionsLinks; use App\Support\Links\RequiredPermissionsLinks;
use Illuminate\Support\Carbon;
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -9,6 +10,7 @@
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : []; $featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : []; $filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : []; $selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
@ -47,17 +49,77 @@
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide'; $adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
$reRunUrl = $this->reRunVerificationUrl(); $reRunUrl = $this->reRunVerificationUrl();
$manageProviderConnectionUrl = $this->manageProviderConnectionUrl();
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
$lastRefreshedLabel = $lastRefreshedAt ? Carbon::parse($lastRefreshedAt)->diffForHumans() : 'Unknown';
$isStale = (bool) ($freshness['is_stale'] ?? true);
$hasStoredPermissionData = $lastRefreshedAt !== null;
$issues = [];
if ($missingApplication > 0) {
$issues[] = [
'severity' => 'Blocker',
'title' => 'Missing application permissions',
'description' => "{$missingApplication} required application permission(s) are missing.",
'links' => array_values(array_filter([
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : null,
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
])),
];
}
if ($missingDelegated > 0) {
$issues[] = [
'severity' => 'Warning',
'title' => 'Missing delegated permissions',
'description' => "{$missingDelegated} delegated permission(s) are missing.",
'links' => [
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
],
];
}
if ($errorCount > 0) {
$issues[] = [
'severity' => 'Warning',
'title' => 'Verification results need review',
'description' => "{$errorCount} permission row(s) are in an unknown/error state and require follow-up.",
'links' => [
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : ['label' => 'Admin consent guide', 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), 'external' => true],
],
];
}
if ($isStale) {
$issues[] = [
'severity' => 'Warning',
'title' => 'Freshness warning',
'description' => $hasStoredPermissionData
? "Permission data is older than 30 days (last refresh {$lastRefreshedLabel})."
: 'No stored verification data is available yet.',
'links' => [
['label' => 'Start verification', 'url' => $reRunUrl, 'external' => false],
],
];
}
@endphp @endphp
<x-filament::page> <x-filament::page>
<div class="space-y-6"> <div class="space-y-6">
<x-filament::section> <x-filament::section heading="Summary">
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }"> <div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-1"> <div class="space-y-1">
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Review whats missing for this tenant and copy the missing permissions for admin consent. Review whats missing for this tenant and copy the missing permissions for admin consent.
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}.
</div>
@if ($overallSpec) @if ($overallSpec)
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon"> <x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
@ -86,6 +148,16 @@
</div> </div>
</div> </div>
@if (! $hasStoredPermissionData)
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
<div class="font-semibold">Keine Daten verfügbar</div>
<div class="mt-1">
Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor.
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
</div>
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200"> <div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div> <div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
@ -322,7 +394,75 @@ class="mt-4 space-y-2"
</div> </div>
</x-filament::section> </x-filament::section>
<x-filament::section heading="Details"> <x-filament::section heading="Issues">
@if ($issues === [])
<div class="rounded-xl border border-success-200 bg-success-50 p-4 text-sm text-success-800 dark:border-success-800 dark:bg-success-950/30 dark:text-success-200">
No blockers or warnings detected from stored data.
</div>
@else
<div class="space-y-3">
@foreach ($issues as $issue)
@php
$severity = (string) ($issue['severity'] ?? 'Warning');
$severityColor = $severity === 'Blocker' ? 'danger' : 'warning';
$title = (string) ($issue['title'] ?? 'Issue');
$description = (string) ($issue['description'] ?? '');
$links = is_array($issue['links'] ?? null) ? $issue['links'] : [];
@endphp
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$severityColor" size="sm">{{ $severity }}</x-filament::badge>
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $title }}</div>
</div>
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">{{ $description }}</div>
@if ($links !== [])
<div class="mt-3 flex flex-wrap gap-3 text-sm">
@foreach ($links as $link)
@php
$label = is_array($link) ? (string) ($link['label'] ?? '') : '';
$url = is_array($link) ? (string) ($link['url'] ?? '') : '';
$external = is_array($link) ? (bool) ($link['external'] ?? false) : false;
@endphp
@if ($label !== '' && $url !== '')
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($external)
target="_blank"
rel="noreferrer"
@endif
>
{{ $label }}
</a>
@endif
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
</x-filament::section>
<x-filament::section heading="Passed">
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">
{{ $presentCount }} permission(s) currently pass.
</div>
<div class="mt-1">
{{ $requiredTotal > 0 ? "Out of {$requiredTotal} required permissions, {$presentCount} are currently granted." : 'No required permissions are configured yet.' }}
</div>
</div>
</x-filament::section>
<x-filament::section heading="Technical details">
<details data-testid="technical-details" class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer list-none text-sm font-semibold text-gray-900 dark:text-white">
Expand technical details
</summary>
<div class="mt-4">
@if (! $tenant) @if (! $tenant)
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
No tenant selected. No tenant selected.
@ -507,6 +647,8 @@ class="align-top"
@endif @endif
</div> </div>
@endif @endif
</div>
</details>
</x-filament::section> </x-filament::section>
</div> </div>
</x-filament::page> </x-filament::page>

View File

@ -0,0 +1,37 @@
# Specification Quality Checklist: Action Surface Contract + CI Enforcement
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-08
**Feature**: specs/082-action-surface-contract/spec.md
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation run: 2026-02-08
- No placeholders remain; no [NEEDS CLARIFICATION] markers.
- Requirements include explicit RBAC semantics (not-found vs forbidden), safety confirmations, and exemption rules.
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@ -0,0 +1,102 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/schemas/action-surface-declaration.schema.json",
"title": "ActionSurfaceDeclaration",
"type": "object",
"additionalProperties": false,
"required": ["version", "componentType", "profile", "defaults", "slots"],
"properties": {
"version": {
"type": "integer",
"minimum": 1
},
"componentType": {
"type": "string",
"enum": ["Resource", "Page", "RelationManager"]
},
"profile": {
"type": "string",
"enum": [
"CrudListAndEdit",
"CrudListAndView",
"ListOnlyReadOnly",
"RunLog"
]
},
"defaults": {
"type": "object",
"additionalProperties": false,
"required": ["moreGroupLabel"],
"properties": {
"moreGroupLabel": {
"type": "string",
"const": "More"
},
"exportIsDefaultBulkActionForReadOnly": {
"type": "boolean"
}
}
},
"slots": {
"type": "object",
"additionalProperties": false,
"properties": {
"ListHeader": { "$ref": "#/$defs/slotRequirement" },
"ListRowPrimary": { "$ref": "#/$defs/slotRequirement" },
"ListRowMoreMenu": { "$ref": "#/$defs/slotRequirement" },
"ListBulkMoreGroup": { "$ref": "#/$defs/slotRequirement" },
"ListEmptyState": { "$ref": "#/$defs/slotRequirement" },
"DetailHeader": { "$ref": "#/$defs/slotRequirement" }
}
},
"exemptions": {
"type": "array",
"items": { "$ref": "#/$defs/exemption" }
}
},
"$defs": {
"slotRequirement": {
"type": "object",
"additionalProperties": false,
"required": ["status"],
"properties": {
"status": {
"type": "string",
"enum": ["Satisfied", "Exempt"]
},
"details": {
"type": "string"
},
"requiresTypedConfirmation": {
"type": "boolean",
"default": false
}
}
},
"exemption": {
"type": "object",
"additionalProperties": false,
"required": ["slot", "reason"],
"properties": {
"slot": {
"type": "string",
"enum": [
"ListHeader",
"ListRowPrimary",
"ListRowMoreMenu",
"ListBulkMoreGroup",
"ListEmptyState",
"DetailHeader"
]
},
"reason": {
"type": "string",
"minLength": 1
},
"trackingRef": {
"type": "string"
}
}
}
}
}

View File

@ -0,0 +1,81 @@
# Data Model — Action Surface Contract
This feature introduces a small, explicit model for declaring and validating action surfaces.
## Entities
### ActionSurfaceDeclaration
Represents the contract declaration attached to a Filament component.
**Fields**:
- `version` (int): schema version for forward compatibility (start at `1`).
- `componentType` (enum): `Resource | Page | RelationManager`.
- `className` (string): fully qualified class name (filled by validator when reporting).
- `panelScope` (enum): `Tenant | Admin` (filled by validator based on discovery context).
- `profile` (enum): contract profile (see below).
- `slots` (map<ActionSurfaceSlot, SlotRequirement>): required and optional slots for this component.
- `exemptions` (list<ActionSurfaceExemption>): explicit exemptions with reason.
- `defaults` (ActionSurfaceDefaults): shared UX defaults (e.g., `moreGroupLabel`).
### ActionSurfaceSlot (enum)
Discrete UI locations that may contain actions.
Proposed baseline slots:
- `ListHeader`
- `ListRowPrimary`
- `ListRowMoreMenu`
- `ListBulkMoreGroup`
- `ListEmptyState`
- `DetailHeader`
(Profiles determine which slots are required.)
### SlotRequirement
Requirement status for a single slot.
**Fields**:
- `status` (enum): `Satisfied | Exempt`.
- `details` (optional string): freeform explanation or expected actions.
- `requiresTypedConfirmation` (bool, default false): indicates the actions in this slot require typed confirmation.
### ActionSurfaceExemption
Represents a justified opt-out for a required slot.
**Fields**:
- `slot` (ActionSurfaceSlot)
- `reason` (string, required, non-empty)
- `trackingRef` (optional string)
### ActionSurfaceDefaults
Shared defaults enforced/checked by CI.
**Fields**:
- `moreGroupLabel` (string, required): must be `More`.
- `exportIsDefaultBulkActionForReadOnly` (bool): default expectation for ReadOnly + RunLog profiles.
## Profiles (enum)
Profiles map component archetypes to required slots.
Proposed profiles (initial set):
- `CrudListAndEdit`: list + edit page exists.
- `CrudListAndView`: list + view page exists.
- `ListOnlyReadOnly`: list-only, no edit/view.
- `RunLog`: operational log list (read-only, expects Export bulk action by default).
## Validation rules (CI)
For each in-scope class:
- Must define an action surface declaration.
- Must select a profile.
- For every slot required by that profile:
- slot must be present with `Satisfied`, OR
- slot must be marked `Exempt` and there must be a matching exemption with non-empty reason.
- Defaults must include `moreGroupLabel = "More"`.
Note: The validator is intentionally declarative; it validates the presence and completeness of the declaration (and exemptions), not the exact runtime Filament action configuration.

View File

@ -0,0 +1,108 @@
# Implementation Plan: Action Surface Contract + CI Enforcement
**Branch**: `082-action-surface-contract` | **Date**: 2026-02-08 | **Spec**: specs/082-action-surface-contract/spec.md
**Input**: Feature specification from specs/082-action-surface-contract/spec.md
## Summary
Introduce a declarative “Action Surface Declaration” for every in-scope admin UI component (Resources, Pages, embedded relationship sub-lists), enforce it via a deterministic Pest guard in CI, and add representative runtime tests for constitution-critical action behavior.
Key behavior:
- Contract profiles define minimum required action slots (list header/row/bulk/empty-state, detail header).
- Declarations must satisfy each required slot or explicitly exempt it with a reason.
- CI fails on missing declarations, missing required slots, or empty exemption reasons.
- Representative runtime tests verify grouping/confirmation/RBAC/canonical-run-link behavior on selected surfaces.
## Technical Context
**Language/Version**: PHP 8.4.x
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (PHPUnit 12 runner)
**Target Platform**: Web application (Laravel Sail for local; Dokploy for deploy)
**Project Type**: Laravel monolith (admin console via Filament panels)
**Performance Goals**: Contract validation completes in < 1s locally/CI (pure PHP, no network)
**Constraints**:
- Deterministic CI enforcement (no Livewire browser interactions)
- No new external calls introduced
- Must preserve RBAC semantics (non-member 404; member missing capability 403)
- Must not change panel architecture (tenant/admin/system panels remain)
- No deep runtime introspection of every Filament action in CI for this iteration
**Scale/Scope**: ~100 Filament classes (Resources/Pages/RelationManagers); enforcement is repo-wide within scope
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: N/A (this feature is UI governance; no inventory model changes)
- Read/write separation: N/A (no new domain writes introduced by the validator)
- Graph contract path: PASS (no new Graph calls)
- Deterministic capabilities: PASS (capability rules stay registry-driven)
- RBAC-UX: PASS (enforcement continues to use central UI helpers; server-side remains authoritative)
- Run observability: PASS (validator runs in CI only; no operations started)
- Data minimization: PASS (no new storage)
- Badge semantics (BADGE-001): N/A
- Filament UI Action Surface Contract: PASS (this feature implements the CI gate itself)
## Plan Phases
### Phase 0 — Research (output: research.md)
Decide and document:
- How to enumerate in-scope classes for tenant + admin panels (excluding widgets)
- How declarations are attached (static method vs attributes vs config)
- What “slots” exist and how exemptions are represented (reason required; tracking optional)
- How the Pest guard reports actionable failures
### Phase 1 — Design (outputs: data-model.md, contracts/*, quickstart.md)
Design artifacts:
- Data model for declarations, slots, exemptions, and profiles
- JSON schema contract for the declaration payload (for future tooling)
- Quickstart explaining how to add a declaration + how to run the guard
### Phase 2 — Planning (output: tasks.md later via /speckit.tasks)
Implementation plan will include:
- New support classes under `app/Support/Ui/ActionSurface/**`
- Pest guard test under `tests/Feature/Guards/**`
- Minimal retrofit for existing “bulk none” surfaces via declarations + targeted exemptions (repo-green first)
- Representative runtime assertions for action grouping, confirmation, and canonical run links
## Project Structure
### Documentation (this feature)
```text
specs/082-action-surface-contract/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Resources/
│ ├── Pages/
│ └── System/ # out-of-scope for this features validator
├── Providers/Filament/ # panel providers (tenant/admin/system)
└── Support/
├── Rbac/ # UiEnforcement + WorkspaceUiEnforcement (existing)
└── Ui/ActionSurface/ # NEW: profiles, slots, declarations, validator
tests/
└── Feature/
└── Guards/ # NEW: Action surface contract CI gate
```
**Structure Decision**: Laravel monolith. Action-surface enforcement ships as support-domain classes + a Pest guard.
## Complexity Tracking
No constitution violations required.

View File

@ -0,0 +1,34 @@
# Quickstart — Action Surface Contract
This quickstart describes how developers will satisfy the Spec 082 contract once the validator is implemented.
## Add a declaration to a Filament component
For an in-scope class (Resource, Page, RelationManager), add a static declaration method.
Example (shape only; exact namespaces/classes defined in implementation):
- Add `public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration`
- Select a `profile`
- Declare required slots as satisfied
- Keep defaults aligned (group label `More`)
## Exempt a slot
If a required slot is intentionally not present:
- Mark the slot as `Exempt`
- Add an exemption entry with a non-empty `reason`
- Optionally provide a `trackingRef`
## Run locally
- Run the contract guard test:
- `vendor/bin/sail artisan test --compact --filter=ActionSurfaceContract`
## Interpreting failures
Failures will point to:
- the class missing a declaration
- which required slots are missing
- which exemptions are invalid (missing/empty reason)
The failure output is intended to be actionable, similar to existing guard tests in `tests/Feature/Guards/*`.

View File

@ -0,0 +1,104 @@
# Research — Action Surface Contract + CI Enforcement
This document resolves planning unknowns for Spec 082 and records key design decisions.
## Decision 1 — Scope enumeration (Tenant + Admin panels; exclude Widgets)
**Decision**: The validator enumerates in-scope Filament classes by scanning `app/Filament/**` for Resources, Pages, and RelationManagers and then filtering to:
- `app/Filament/Resources/**` (tenant panel discovery)
- `app/Filament/Pages/**` (tenant panel discovery)
- `app/Filament/RelationManagers/**` (used by resources)
- plus any admin-panel explicit resources/pages registered via `app/Providers/Filament/AdminPanelProvider.php`
Widgets are explicitly excluded.
**Rationale**:
- Matches clarified spec scope (Tenant + Admin panels; Resources/Pages/RelationManagers; exclude Widgets).
- Deterministic and fast (filesystem + reflection; no UI runtime required).
- Aligns with existing repo patterns (guard tests that scan filesystem and fail with actionable output).
**Alternatives considered**:
- Discovering via Filament panel runtime discovery: rejected (adds runtime coupling and can be non-deterministic in CI).
- Enumerating only by filesystem: accepted as baseline, but we still need a small allowlist for admin explicit registrations.
## Decision 2 — Declaration attachment mechanism
**Decision**: Each in-scope class provides a declaration via a static method (e.g., `public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration`).
**Rationale**:
- PHP-first, simple, no attribute parsing or config conventions.
- Easy to require/validate by reflection.
- Plays well with existing Filament static patterns (Resources already use statics like `$recordTitleAttribute`).
**Alternatives considered**:
- PHP 8 attributes: rejected for now (slower adoption, harder to keep structured without additional tooling).
- Central registry config: rejected (drifts away from the component, discourages ownership).
## Decision 3 — Contract enforcement: “declare or exempt” + representative runtime checks
**Decision**: CI enforces that each in-scope class has a declaration and that every required slot is either marked satisfied or has an explicit exemption with a non-empty reason.
The validator does not attempt to prove that every underlying Filament `table()` / `getHeaderActions()` actually contains those actions. Instead, representative runtime tests cover constitution-critical behavior (grouping, confirmation, RBAC semantics, canonical run links).
**Rationale**:
- Deterministic and robust across Filament internal changes.
- Avoids brittle source parsing and avoids needing Livewire/Filament runtime contexts.
- Still forces explicit human intent on each surface, which is the governance goal.
**Alternatives considered**:
- AST/static parsing of PHP to verify `->actions()` / `->bulkActions()` usage: rejected (high complexity and brittleness).
- Instantiating Filament objects and introspecting action definitions for every surface in CI: rejected initially (risk of boot/DI coupling). Representative runtime tests are used instead for critical guarantees.
## Decision 4 — Profiles and required “slots”
**Decision**: Model “profiles” that map to component archetypes (e.g., list-only, list+detail, read-only list, run-log list). Each profile defines required slots:
- List header actions
- List row actions (max 2 visible; rest in “More”)
- List bulk actions (grouped; “More” label)
- List empty-state actions
- Detail header actions (if the component has a detail view/edit/view page)
**Rationale**:
- Keeps enforcement simple and consistent.
- Allows targeted exceptions by profile without exploding per-component logic.
**Alternatives considered**:
- One giant universal profile: rejected (too many exemptions, low signal).
## Decision 5 — Exemption policy
**Decision**: Exemptions require:
- a non-empty reason string
- optional tracking reference (issue/PR link)
- no deadline requirement
**Rationale**:
- Matches clarified spec.
- Keeps CI deterministic (no time-based expiry).
**Alternatives considered**:
- Required expiry dates: rejected (creates “time bombs” in CI).
## Decision 6 — Standard grouping label and safe-default Export
**Decision**:
- The canonical group label for secondary row actions and bulk action groups is `More`.
- For `ReadOnly` + `RunLog` profiles, the default expected safe bulk action is `Export` (otherwise exemption required).
**Rationale**:
- Matches clarified spec.
- Provides a consistent, predictable UX baseline.
**Alternatives considered**:
- Allowing arbitrary labels: rejected (inconsistent UX; harder to audit).
## Decision 7 — Typed confirmation threshold
**Decision**: The contract model includes a `requiresTypedConfirmation` flag for actions that are destructive/irreversible, affect > 25 records, or change access/credentials/safety gates.
**Rationale**:
- Matches clarified spec.
- Keeps enforcement declarative and reviewable.
**Alternatives considered**:
- Always requiring typed confirmations: rejected (too heavy for routine admin workflows).

View File

@ -0,0 +1,191 @@
# Feature Specification: Action Surface Contract + CI Enforcement
**Feature Branch**: `082-action-surface-contract`
**Created**: 2026-02-08
**Status**: Draft
**Input**: User description: "Spec 082 — Action Surface Contract + CI Enforcement"
## Clarifications
### Session 2026-02-08
- Q: Group label standard: “More” vs “Actions” → A: Use “More” as the standard label for secondary row actions and bulk action groups.
- Q: Standard safe bulk action for ReadOnly/RunLog: allow Export everywhere? → A: Yes — Export is the default safe bulk action for ReadOnly and RunLog surfaces when data exists; otherwise an explicit exemption with reason is required.
- Q: Exemption policy: time-boxed vs reason-only? → A: Exemptions must include a non-empty reason; an optional tracking link/reference may be included; no deadline is required.
- Q: Audit policy: destructive-only vs all mutations? → A: Audit events are required for all mutations and for operation-start triggers (run/sync/verify/dispatch).
- Q: Validator scope/discovery: which components are in-scope for CI enforcement? → A: Tenant + Admin panels; validate Resources, Pages, and embedded relationship sub-lists; exclude Widgets.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Prevent incomplete UI action surfaces (Priority: P1)
As a developer, when I add or modify an admin-console UI component (list, detail, embedded relationship sub-list), I must explicitly declare its expected action surface (or explicitly exempt parts with a reason), so that automated checks prevent regressions before merge.
**Why this priority**: This is the regression gate. Without it, incomplete action surfaces ship repeatedly and create inconsistent UX and RBAC drift.
**Independent Test**: Add a minimal new UI component without an action surface declaration and confirm the automated gate fails with a precise message; then add a declaration (or exemption) and confirm the gate passes.
**Acceptance Scenarios**:
1. **Given** a new or modified in-scope UI component without an action surface declaration, **When** validation runs, **Then** it fails with a message naming the component and the missing required slots.
2. **Given** a component with a complete declaration (or exemptions with reasons), **When** validation runs, **Then** it passes.
3. **Given** a component uses an exemption, **When** validation runs, **Then** it fails if the exemption reason is missing or empty.
---
### User Story 2 - Consistent, enterprise-grade actions and empty-state guidance (Priority: P2)
As an admin-console user, I see predictable and consistent actions across lists and details: primary actions are obvious, secondary actions are grouped, bulk actions exist where appropriate, and empty states provide an actionable next step.
**Why this priority**: Reduces training cost and operational mistakes; improves speed and confidence in admin workflows.
**Independent Test**: Pick one CRUD-style list and one run-log style list; verify each has the expected action areas (header/row/bulk/empty-state; detail header actions) and that ordering/grouping conventions are consistent.
**Acceptance Scenarios**:
1. **Given** a list view with records, **When** a user inspects available actions, **Then** they see at most two visible row actions and all secondary actions are grouped under a consistent label.
2. **Given** a list view with zero records, **When** the page is shown, **Then** it includes at least one CTA that helps resolve the empty state.
3. **Given** a list view that supports selection, **When** the user selects one or more records, **Then** at least one bulk action is available or the UI explicitly documents why bulk is intentionally not offered.
---
### User Story 3 - Predictable RBAC behavior for members vs non-members (Priority: P3)
As a security reviewer, I need consistent authorization semantics across all in-scope actions:
- A non-member must not discover tenant/workspace surfaces (deny-as-not-found).
- A member without the required capability must be prevented from completing the action (deny-as-forbidden), and the UI must communicate why.
**Why this priority**: Prevents information disclosure and capability drift; keeps the product aligned with least privilege.
**Independent Test**: Use a representative surface and verify (a) non-member receives not-found, and (b) member without capability receives forbidden and sees disabled UI with a helpful explanation.
**Acceptance Scenarios**:
1. **Given** a user who is not a member of the relevant tenant/workspace scope, **When** they attempt to access a related page or record, **Then** the response is not-found.
2. **Given** a user who is a member but lacks capability for a specific action, **When** they attempt to execute the action, **Then** the response is forbidden.
3. **Given** a member without capability viewing the UI, **When** they inspect the action surface, **Then** disallowed actions are visible-but-disabled with a clear explanation (not silently missing).
### Edge Cases
- A surface is intentionally read-only and has no meaningful bulk actions (must be explicitly exempted with a reason).
- A user loses membership/capability between page load and action execution (server-side enforcement must still apply).
- Bulk mutations above a risk threshold must require stronger confirmation (typed confirmation) to reduce accidental damage.
- Empty states that cannot be resolved from within the current surface (CTA should route users to the correct “next step” surface).
- Global search and cross-scope links must not reveal existence of tenant/workspace data to non-members.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is primarily a UI governance and authorization-consistency feature. It MUST not introduce new external calls or long-running operations as part of the enforcement gate.
**Constitution alignment (RBAC-UX):** This feature formalizes 404 vs 403 semantics and requires both UI signaling and server-side enforcement for in-scope actions.
**Constitution alignment (Action Surfaces):** This feature defines and enforces an Action Surface Contract, and introduces a mandatory UI Action Matrix for future admin UI-related specs.
### Functional Requirements
- **FR-001 (Contract profiles)**: The system MUST define a small set of action-surface profiles (e.g., CRUD, ReadOnly, RunLog, embedded relationship sub-lists) that describe minimum expected action areas for list and detail surfaces.
- **FR-002 (Required action slots)**: For each profile, the system MUST define required action slots for:
- list/table header actions,
- row actions,
- bulk actions,
- empty-state CTA(s),
- detail header actions,
- create/edit save/cancel conventions (where applicable).
- **FR-003 (Exemptions)**: The system MUST allow explicit exemptions for specific slots, and each exemption MUST include:
- a non-empty reason, and
- an optional tracking reference (issue/PR link or identifier).
- **FR-004 (Mandatory declaration)**: For every in-scope admin-console UI component that is new or modified, a declaration MUST exist that:
- identifies the profile,
- declares which slots are satisfied,
- lists any exemptions with reasons.
- **FR-005 (Automated gate)**: The system MUST automatically validate contract compliance for all in-scope UI components in the code review pipeline, and MUST fail with actionable messages when requirements are not met.
- **FR-006 (Consistency rules)**: The system MUST enforce the following UX conventions through (a) declaration validation for all in-scope surfaces and (b) runtime tests on representative surfaces:
- no more than two visible row actions (typically View/Edit),
- secondary actions grouped under the standard label “More”,
- bulk actions grouped under the standard label “More”,
- destructive actions are never primary.
- **FR-006a (Default safe bulk action)**: For ReadOnly and RunLog profiles, the default bulk action MUST be “Export” when the surface contains data; if “Export” is not offered, an explicit exemption with a reason is required.
- **FR-007 (Safety + confirmation)**: Any mutating or destructive action MUST require confirmation. For this feature, compliance is enforced via declaration checks plus representative runtime tests for touched surfaces.
- Mutation classes for FR-007:
- data mutation: create/update/delete/archive/restore/attach/detach,
- operation-start trigger: enqueue/dispatch/run/sync/verify/retry actions,
- access/safety mutation: role or membership changes, credential or permission updates, safety gate toggles.
- High-risk bulk actions MUST require typed confirmation when any of the following is true:
- the action is destructive/irreversible, or
- the action affects more than 25 records, or
- the action changes tenant/workspace access, credentials, or operational safety gates.
- **FR-008 (Auditability)**: All mutations and operation-start triggers MUST use existing audit sinks and MUST write an audit event that records at minimum: actor, scope (tenant/workspace), action type, target(s), timestamp, and outcome.
- Audit sinks for FR-008:
- tenant-scoped actions: `App\Services\Intune\AuditLogger` (writes `audit_logs`),
- workspace/platform-scoped actions: `App\Services\Audit\WorkspaceAuditLogger` (writes `audit_logs`).
- Operation-start triggers MAY satisfy FR-008 via canonical `OperationRun` creation when the action is run-oriented and existing architecture uses `OperationRun` as the observability record.
- For this feature, FR-008 is enforced as: no regressions on touched representative surfaces and no new ad-hoc audit sink introduced.
- **FR-009 (RBAC UI gating standard)**: UI gating MUST follow the projects standard semantics:
- non-member → deny-as-not-found behavior,
- member without capability → deny-as-forbidden behavior,
- UI communicates “forbidden” as visible-but-disabled actions with a tooltip/explanation.
- **FR-010 (Server-side authorization)**: Server-side authorization MUST be enforced for every action execution (not just UI visibility) and MUST align with FR-009 semantics (see constitution RBAC-UX-001..004).
- **FR-011 (Canonical run links)**: Any “View run” deep link MUST use the canonical tenantless operations path as the primary link; tenant-scoped convenience links may exist but must not be primary.
- **FR-012 (Scope)**: The contract MUST cover both the tenant-scoped admin console and the platform/admin console surfaces for resources, pages, and embedded relationship sub-lists.
- **FR-012a (Explicit CI scope)**: The automated gate MUST validate Resources, Pages, and embedded relationship sub-lists in both tenant-scoped and platform/admin consoles, and MUST exclude Widgets from enforcement.
### Non-Goals
- This feature does not redesign visual layout or page styling.
- This feature does not change domain workflows (sync/restore/verify), except to ensure action surfaces are complete or explicitly exempted.
- This feature does not change panel architecture or audience boundaries.
### Assumptions
- A safe, broadly applicable bulk action exists for read-only/run-log surfaces (“Export”) where data volume and permissions allow.
- Some existing surfaces will require temporary exemptions to keep the repository in a releasable state while retrofit work is completed.
### Enforcement Boundaries
- The CI validator for this feature is declaration-driven and deterministic (filesystem + reflection only).
- Runtime behavior is verified via representative tests for each profile and each critical rule family (grouping, confirmation, RBAC semantics, canonical run links).
- Deep runtime introspection of every Filament action in CI is explicitly out of scope for this iteration.
## UI Action Matrix *(mandatory when admin UI components are changed)*
_Note: This matrix applies whenever admin UI components (resources, pages, or embedded relationship sub-lists) are added or changed._
This feature introduces a contract that applies broadly. The matrix below documents the minimum action surface expectations by profile.
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|
| CRUD list + detail | Tenant panel + Admin panel | Primary: Create (if allowed). Optional: domain CTA (e.g., Sync/Run) | Primary: View, Edit (if allowed). Secondary: grouped under “More” | At least 1 bulk action (e.g., Archive/Restore/Export) | Primary CTA: Create or domain CTA | Primary: Edit (if allowed). Secondary grouped under “More” | Consistent Save + Cancel; no destructive primary | Yes (for mutations + operation-start) | Exempt bulk only with explicit reason (e.g., no safe bulk operation exists) |
| ReadOnly list + detail | Tenant panel + Admin panel | At least 1 CTA that provides value (e.g., Export/Refresh) | Primary: View. Secondary grouped under “More” | Bulk: Export (default) | CTA that resolves empty state (e.g., Refresh now) | “More” group for secondary actions | N/A | Maybe (typically for operation-start actions) | Exempt Export only with explicit reason (e.g., legal/security constraints) |
| RunLog list + detail | Tenant panel + Admin panel | Optional CTA routing to Operations hub | Primary: View | Bulk: Export (default) or Prune if retention exists; otherwise exempt with reason | Optional CTA routing to Operations hub | “View run” canonical link; optional Export | N/A | Maybe (for prune/retention changes) | “View run” must be canonical tenantless as primary deep link |
| Embedded relationship sub-list | Under parent detail | Header: Add/Attach/Create/Refresh depending on context | Primary: View and one context action (e.g., Detach/Remove). Secondary grouped under “More” | At least 1 bulk action or exemption with reason | CTA: next step (Add/Attach/Refresh) | N/A | N/A | Yes (for mutations) | Bulk may be exempted if relationship action is inherently single-record |
### Key Entities *(include if feature involves data)*
- **Action Surface Declaration**: A structured, human-reviewed statement of what actions a surface offers (and why some may be exempt).
- **Action Surface Profile**: A category of surfaces with shared minimum action expectations (CRUD, ReadOnly, RunLog, embedded relationship sub-list).
- **Action Slot**: A required location/type of action (header, row, bulk, empty-state, detail header, save/cancel).
- **Exemption**: A documented, explicit deviation from the contract for a specific slot.
- **Capability**: The permission concept used to decide whether an action is allowed.
- **Audit Event**: A record that an action was attempted/executed and its outcome.
### Dependencies
- A single, canonical capability registry exists and is used consistently for authorization decisions.
- Standard UI gating helpers exist for tenant/workspace scopes and are the only allowed approach for UI enable/disable behavior.
- A centralized audit-event sink exists to record user-triggered mutations and operation starts.
### Acceptance Notes (verification approach)
- Contract compliance is verified via an automated validation gate that enumerates in-scope UI components and checks required slots/exemptions.
- Authorization correctness is verified via representative automated tests that prove not-found vs forbidden behavior across at least one surface per profile.
- Consistency/confirmation/canonical-link behavior is verified on representative runtime surfaces; declaration checks remain the repo-wide completeness gate.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Coverage)**: 100% of in-scope UI components are contract-compliant or have explicit exemptions with reasons.
- **SC-002 (Regression prevention)**: Any new/modified in-scope UI component without a declaration fails validation in the review pipeline.
- **SC-003 (Action completeness)**: For all CRUD and ReadOnly list surfaces, the selection state always presents at least one bulk action or an explicit exemption.
- **SC-004 (Authorization correctness)**: At least one representative surface per profile has automated tests proving not-found vs forbidden behavior (non-member vs member without capability).
- **SC-005 (Developer experience)**: Validation failures provide actionable messages that identify the component and missing slot(s), enabling a fix without additional investigation.

View File

@ -0,0 +1,127 @@
---
description: "Task list for Spec 082 implementation"
---
# Tasks: Action Surface Contract + CI Enforcement
**Input**: Design documents from `/specs/082-action-surface-contract/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md
**Tests**: Required (Pest) — this feature adds/changes CI enforcement.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Introduce the new support namespace and a guard-test entry point.
- [X] T001 Create ActionSurface namespaces in app/Support/Ui/ActionSurface/
- [X] T002 [P] Add guard test skeleton in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T003 [P] Add validator test skeleton in tests/Feature/Guards/ActionSurfaceValidatorTest.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core contract model + discovery + validation that all user stories rely on.
- [X] T004 Create enums for component/profile/slot in app/Support/Ui/ActionSurface/Enums/ActionSurfaceComponentType.php
- [X] T005 [P] Create slot + exemption value objects in app/Support/Ui/ActionSurface/ActionSurfaceExemption.php
- [X] T006 [P] Create declaration + defaults DTOs in app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php
- [X] T007 Implement profile definitions (required slots per profile) in app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php
- [X] T008 Implement in-scope discovery (tenant discovery roots + admin explicit registrations/routes; exclude Widgets and System panel) in app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
- [X] T009 Implement declaration contract validation (declare-or-exempt; reason required; More label; Export default) in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- [X] T010 Add fast unit-style tests for validator rules in tests/Feature/Guards/ActionSurfaceValidatorTest.php (pure validation logic, no DB or Filament boot)
**Checkpoint**: Validator can enumerate + validate in-scope components deterministically.
---
## Phase 3: User Story 1 — Prevent incomplete UI action surfaces (Priority: P1) 🎯 MVP
**Goal**: CI fails if an in-scope Filament component lacks a declaration (or an explicit exemption with a non-empty reason).
**Independent Test**: Add a new in-scope Filament component without a declaration; run the guard and confirm it fails with an actionable message; add a declaration/exemption and confirm it passes.
- [X] T011 [US1] Implement the CI guard that runs validation in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T012 [US1] Add baseline exemptions registry in app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php (reason required, explicit class allowlist with shrink-over-time rule)
- [X] T013 [US1] Wire exemptions into validator output in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- [X] T014 [US1] Make guard output actionable (class + slot + hint) in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T015 [US1] Add tests proving widgets are excluded from scope in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T031 [US1] Add tests proving unknown classes are not auto-exempted and every exemption reason is non-empty in tests/Feature/Guards/ActionSurfaceContractTest.php
**Checkpoint**: Guard passes on main branch state, but blocks missing declarations going forward.
---
## Phase 4: User Story 2 — Consistent, enterprise-grade actions and empty-state guidance (Priority: P2)
**Goal**: Provide consistent action-surface conventions (More grouping, Export default for RunLog/ReadOnly, empty-state CTA) and demonstrate them on representative surfaces.
**Independent Test**: Pick one CRUD-style list and one run-log style list; verify each has expected action areas (header/row/bulk/empty-state; detail header actions) and consistent ordering/grouping.
- [X] T016 [P] [US2] Define required slots for CRUD/ReadOnly/RunLog/RelationManager profiles in app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php
- [X] T017 [P] [US2] Enforce defaults: More label + Export expectation for ReadOnly/RunLog in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- [X] T018 [US2] Add a declaration to a representative CRUD surface in app/Filament/Resources/PolicyResource.php
- [X] T019 [US2] Ensure list empty-state CTA exists for the CRUD surface in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php
- [X] T020 [US2] Add a declaration to a representative RunLog surface in app/Filament/Resources/OperationRunResource.php
- [X] T021 [US2] Ensure Export bulk action is present (or exempted with reason) for the RunLog surface in app/Filament/Resources/OperationRunResource.php
- [X] T022 [US2] Add a declaration to one embedded relationship sub-list in app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php
- [X] T023 [US2] Add tests asserting the representative declarations satisfy required slots in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T032 [US2] Add representative runtime test asserting PolicyResource row/bulk grouping uses “More” conventions in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T033 [US2] Add representative runtime test asserting canonical tenantless “View run” link usage in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T035 [US2] Add golden regression tests for “view-only lists use clickable rows without row actions” in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T036 [US2] Document action-surface contract rules (including “no lone View button; prefer clickable rows”) in docs/ui/action-surface-contract.md
**Checkpoint**: At least one CRUD + one RunLog + one embedded sub-list visibly follow the conventions.
---
## Phase 5: User Story 3 — Predictable RBAC behavior for members vs non-members (Priority: P3)
**Goal**: Validate consistent 404 vs 403 semantics and disabled-UI behavior for capability-gated actions.
**Independent Test**: Use a representative surface and verify (a) non-member receives not-found, and (b) member without capability receives forbidden and sees disabled UI with a helpful explanation.
- [X] T024 [P] [US3] Add non-member 404 route test on representative operations detail route in tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
- [X] T025 [US3] Add member-without-capability 403 server-side test by attempting to execute ListEntraGroupSyncRuns::sync_groups as a readonly member in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
- [X] T026 [US3] In that 403 test, assert no EntraGroupSyncJob is pushed and no EntraGroupSyncRun is created for the tenant in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
- [X] T027 [US3] Ensure disabled-UI semantics are covered for the same action (visible + disabled + standard tooltip) in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
- [X] T034 [US3] Add representative observability/audit regression assertion for an operation-start action (OperationRun record present with scope + actor) in tests/Feature/Guards/ActionSurfaceContractTest.php
**Checkpoint**: One surface per profile has coverage for 404 vs 403 semantics.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T028 [P] Run formatter on changed files via vendor/bin/sail bin pint --dirty
- [X] T029 Run focused guard tests via vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T030 Run RBAC semantics test via vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
---
## Dependencies & Execution Order
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2/US3 (Phases 45 can proceed in parallel) → Polish (Phase 6)
## Parallel Opportunities
- Phase 2: DTOs/enums (T004T006) can be done in parallel.
- US2: Profile definition + validator rules (T016T017) can be done in parallel.
- US3: Test scaffolding (T024) can be done in parallel with US2 retrofit tasks.
## Parallel Example: US2
- T018 (PolicyResource declaration) can be implemented in parallel with T020 (OperationRunResource declaration).
## Implementation Strategy
### MVP First (US1 only)
1. Complete Phase 1 + Phase 2.
2. Implement Phase 3 (US1) guard + exemptions.
3. Validate guard behavior by adding/removing a declaration locally.
### Incremental Delivery
- Add US2 representative surfaces next (PolicyResource + OperationRunResource + one RelationManager).
- Add US3 RBAC semantics tests last (keeps scope tight while contract infrastructure stabilizes).

View File

@ -0,0 +1,39 @@
# Specification Quality Checklist: Canonical Required Permissions (Manage) Hardening & Enterprise UX
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-08
**Feature**: [specs/083-required-permissions-hardening/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
Validation run (2026-02-08):
- Spec includes explicit 404 vs 403 semantics (deny-as-not-found for non-entitlement).
- Legacy URL non-existence is explicitly required and covered by test requirements.
- DB-only rendering constraint is explicitly required and test-covered.

View File

@ -0,0 +1,27 @@
# Route Contract — Spec 083
This contract defines the **Required Permissions** routes and their **404/403 semantics**.
## Canonical management surface (must exist)
- `GET /admin/tenants/{tenant}/required-permissions`
Identifier contract:
- `{tenant}` is `Tenant.external_id` (Entra tenant GUID)
Authorization contract:
- Not authenticated → handled by Filament auth middleware
- Workspace not selected → 404 (deny-as-not-found)
- Not a workspace member → 404
- Workspace member but **not tenant-entitled** (no `tenant_memberships` row) → 404
- Tenant-entitled (including read-only) → 200
Action contract:
- This page is read-only. Any mutations are only linked to and executed on other surfaces.
- Mutations on other surfaces must enforce capability checks server-side (missing capability → 403).
- "Re-run verification" links canonical to the start-verification surface: `GET /admin/onboarding` (generated via route helper, not hardcoded legacy paths).
## Removed tenant-plane route (must 404)
The following route MUST NOT exist and MUST return 404 (no redirects, no aliases):
- `GET /admin/t/{tenant}/required-permissions`

View File

@ -0,0 +1,40 @@
# Data Model — Spec 083
This feature is primarily **read-only UX + authorization hardening**. No new tables are required.
## Existing entities (relevant)
### Workspace
- **Purpose**: Isolation boundary for tenant management surfaces.
- **Key fields**: `id`.
### WorkspaceMembership
- **Purpose**: Establishes user membership in a workspace.
- **Key fields**: `workspace_id`, `user_id`, `role`.
### Tenant
- **Purpose**: Managed Entra tenant (scoped to a workspace).
- **Key fields**: `id`, `external_id` (Entra tenant GUID), `workspace_id`, `status`, `name`.
### TenantMembership
- **Purpose**: Tenant entitlement (read-only access at minimum).
- **Key fields**: `tenant_id`, `user_id`, `role`, `source`, `source_ref`.
### TenantPermission
- **Purpose**: Stored permission inventory used by Required Permissions page.
- **Key fields**: `tenant_id`, `permission_key`, `status` (`granted|missing|error`), `details` (JSON), `last_checked_at`.
## Derived / computed values
### "Last refreshed"
- **Definition**: `max(tenant_permissions.last_checked_at)` for the tenant.
- **Stale rule** (Spec 083): stale if missing OR older than 30 days.
### Summary overall status
Derived from stored permission rows (and freshness):
- **Blocked**: any missing `application` permission.
- **Needs attention**: any warning exists (missing delegated OR error rows folded into warning OR stale freshness).
- **Ready**: no blockers, no warnings.
## State transitions
- None introduced here (page remains read-only). Mutations happen on other surfaces (verification start, provider connection management) and must enforce capability checks there.

View File

@ -0,0 +1,204 @@
# Implementation Plan: 083-required-permissions-hardening
**Branch**: `083-required-permissions-hardening` | **Date**: 2026-02-08 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Harden the canonical Required Permissions manage surface so it is only accessible via `GET /admin/tenants/{tenant}/required-permissions`, enforces deny-as-not-found (404) when the actor is not workspace-member or not tenant-entitled, removes any cross-plane tenant-context fallback, and presents issues-first UX using **stored DB data only** (no provider calls on render).
Research decisions are captured in [research.md](research.md).
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4, PostgreSQL, Tailwind CSS v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (run via Sail)
**Target Platform**: Web app (Laravel) running in Docker via Sail
**Project Type**: Web application (Laravel + Filament admin panel)
**Performance Goals**: Fast, DB-only page render (no outbound HTTP / Graph calls)
**Constraints**: Strict 404 vs 403 semantics (deny-as-not-found), no cross-plane tenant fallback
**Scale/Scope**: Single page hardening + view-model/UX changes + targeted tests
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: manage surface (`/admin/tenants/...`), tenant plane (`/admin/t/{tenant}/...`), and platform plane (`/system/...`) remain clearly separated; cross-plane access is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
### Gate evaluation (pre-design)
- **Inventory-first / DB-only**: PASS. This surface renders from stored `tenant_permissions` only.
- **Read/write separation**: PASS. The page is read-only; it only links to mutation surfaces.
- **Graph contract path**: PASS. No Graph calls on render; any verification runs remain elsewhere.
- **Deterministic capabilities**: PASS. Access is entitlement-based via tenant membership; capability checks remain on mutation surfaces.
- **RBAC-UX semantics**: PASS (planned). Implement explicit 404 denial for non-members/non-entitled and remove implicit tenant fallback.
- **BADGE-001**: PASS (planned). Use existing overall status enum values (`Blocked`, `NeedsAttention`, `Ready`) and render via existing badge mechanisms.
- **Filament Action Surface Contract**: PASS (exempt-by-design). This is a Filament Page (not a List/Table CRUD surface). It has no row/bulk actions; it is read-only and link-only.
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── TenantRequiredPermissions.php
│ └── Pages/Workspaces/
│ └── ManagedTenantOnboardingWizard.php # Start verification surface (CTA target)
├── Models/
│ ├── Tenant.php
│ ├── TenantPermission.php
│ ├── TenantMembership.php
│ ├── WorkspaceMembership.php
│ └── User.php
└── Services/
├── Auth/CapabilityResolver.php
└── Intune/
├── TenantPermissionService.php
└── TenantRequiredPermissionsViewModelBuilder.php
resources/
└── views/
└── filament/pages/tenant-required-permissions.blade.php
tests/
├── Feature/
│ ├── RequiredPermissions/ # to be created in Phase 2
│ │ ├── RequiredPermissionsAccessTest.php
│ │ ├── RequiredPermissionsDbOnlyRenderTest.php
│ │ ├── RequiredPermissionsEmptyStateTest.php
│ │ ├── RequiredPermissionsLegacyRouteTest.php
│ │ └── RequiredPermissionsLinksTest.php
└── Unit/
├── TenantRequiredPermissionsFreshnessTest.php
└── TenantRequiredPermissionsOverallStatusTest.php
```
**Structure Decision**: Web application (Laravel + Filament admin panel). Changes are localized to the Filament Page, its view-model builder, Blade view, and new targeted tests.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Phase 0 — Outline & Research (complete)
- Consolidated repo reality (existing canonical route, current tenant resolution fallback, current view-model behavior) and made explicit decisions in [research.md](research.md).
- No remaining NEEDS CLARIFICATION items for Spec 083.
## Phase 1 — Design & Contracts (complete)
- Data model notes captured in [data-model.md](data-model.md).
- Route/semantics contract captured in [contracts/routes.md](contracts/routes.md).
- Developer quickstart captured in [quickstart.md](quickstart.md).
## Constitution Check (post-design re-check)
- **Tenant isolation / deny-as-not-found**: PASS (design enforces explicit 404 for non-member/non-entitled).
- **Cross-plane separation**: PASS (design removes `Tenant::current()` fallback on this surface).
- **Read/write separation**: PASS (read-only page; mutation remains capability-gated on other surfaces).
- **DB-only render**: PASS (stored `tenant_permissions` + derived freshness).
- **Filament action contract**: PASS (page is read-only; no list/table actions introduced).
## Phase 1 — Agent context update (required)
Run:
```bash
.specify/scripts/bash/update-agent-context.sh copilot
```
## Phase 2 — Implementation plan (input for tasks.md)
1. **Authorization + 404 semantics (page entry)**
- Update `App\Filament\Pages\TenantRequiredPermissions` to enforce deny-as-not-found (404) when:
- workspace not selected / tenant not found / tenant not in workspace
- actor not workspace member
- actor not tenant-entitled (`User::canAccessTenant($tenant)` false)
- Ensure the checks run on initial page mount, not only in navigation gating.
2. **Remove cross-plane tenant fallback**
- Make `resolveScopedTenant()` strict: only resolve from route `{tenant}` (bound model or `external_id` lookup). If absent/invalid → treat as not found.
3. **DB-only render guarantees**
- Confirm the view-model builder continues to call `TenantPermissionService::compare(... liveCheck:false ...)`.
- Add tests to ensure no outbound HTTP is performed during render.
4. **Issues-first UX + canonical CTAs**
- Update the Blade view to present:
- Summary (overall, counts, freshness)
- Issues (Blockers + Warnings only; no separate “Error” category)
- Passed / Technical details (de-emphasized, Technical collapsed by default)
- Add a dedicated empty-data state (“Keine Daten verfügbar”) with a links-only CTA to start verification.
- Update “Re-run verification” / “Start verification” link-only CTA to point canonical to `/admin/onboarding` via route helper generation.
5. **Freshness / stale detection**
- Extend the view-model to include:
- `last_refreshed_at` derived from stored `tenant_permissions.last_checked_at` (max)
- `is_stale` (missing OR > 30 days)
- Update overall status derivation to include stale as a warning.
6. **Tests (Pest) — minimum set**
- Feature tests for:
- 404 for non-workspace-member
- 404 for workspace-member but not tenant-entitled
- 200 for tenant-entitled read-only
- empty-data state (“Keine Daten verfügbar”) with canonical start-verification CTA
- 404 for legacy route `/admin/t/{tenant}/required-permissions`
- 404 when route tenant missing/invalid (no fallback)
- Summary status mapping + stale threshold
- Technical details rendered after Issues/Passed and collapsed by default
- “Re-run verification” links to `/admin/onboarding`
7. **Scope boundary for FR-083-009**
- This feature does not modify mutation endpoints.
- Capability-based 403 enforcement remains on the linked target surfaces and is treated as an explicit dependency, not newly implemented behavior in Spec 083.
8. **Formatting + verification**
- Run `vendor/bin/sail bin pint --dirty`.
- Run the targeted tests via `vendor/bin/sail artisan test --compact ...`.

View File

@ -0,0 +1,31 @@
# Quickstart — Spec 083
## Dev prerequisites
- Run via Sail (local): Docker + `vendor/bin/sail` available.
## What to validate
### Route semantics
- Canonical (must exist): `GET /admin/tenants/{tenant}/required-permissions`
- Legacy (must 404): `GET /admin/t/{tenant}/required-permissions`
### Authorization semantics
- Non-workspace-member → 404
- Workspace-member but not tenant-entitled → 404
- Tenant-entitled (including read-only) → 200
### Render behavior
- Page render uses stored DB data only (no Graph / no outbound HTTP).
- If no stored permission data exists, page shows "Keine Daten verfügbar" with a canonical CTA to `/admin/onboarding`.
- "Technical details" appears after Issues/Passed and is collapsed by default.
## Run targeted tests
- `vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions/*`
- (Exact filenames to be created in Phase 2 tasks.)
## Manual smoke test
1. Log in to admin panel.
2. Select a workspace.
3. Open `/admin/tenants/{external_id}/required-permissions` for a tenant you are a member of.
4. Confirm Summary + Issues-first layout and that "Re-run verification" links to `/admin/onboarding`.
5. As a user without tenant entitlement, confirm the same URL returns 404.

View File

@ -0,0 +1,63 @@
# Research — Spec 083 (Required Permissions hardening)
## Context recap
- Canonical manage-plane surface already exists as a Filament Page: `App\Filament\Pages\TenantRequiredPermissions` with slug `tenants/{tenant}/required-permissions` (admin panel).
- A legacy tenant-plane path prefix exists (`/admin/t/...`) via the tenant panel; spec requires `/admin/t/{tenant}/required-permissions` to remain non-existent and return 404.
## Decisions
### Decision 1 — Canonical route stays `/admin/tenants/{tenant}/required-permissions`
- **Chosen**: Keep the canonical manage URL exactly as specified in Spec 083.
- **Rationale**: Already aligned with the existing page slug and with the established route contract in Spec 080.
- **Alternatives considered**:
- Redirect from `/admin/t/...` → rejected (spec requires 404, no redirect).
### Decision 2 — Deny-as-not-found is implemented explicitly (404), not via “canAccess() only”
- **Chosen**: Enforce 404 using explicit `abort(404)` checks on request entry (e.g., `mount()`), instead of relying solely on Filaments `canAccess()` return value.
- **Rationale**: Filaments `canAccess()` may produce behavior that is not guaranteed to be a 404. Spec 083 requires strict 404 semantics for non-members / non-entitled.
- **Alternatives considered**:
- Only `canAccess()` returning false → rejected (status code semantics uncertain).
- Route-level middleware on just this page → possible, but still needs explicit entitlement checks; can be added later if desired.
### Decision 3 — Tenant entitlement is checked via `User::canAccessTenant($tenant)`
- **Chosen**: Use `User::canAccessTenant()` for tenant entitlement (no workspace-wide “view all tenants” override).
- **Rationale**: This matches existing patterns across the codebase, uses `tenant_memberships`, and aligns with the clarification outcome.
- **Alternatives considered**:
- Workspace membership only → rejected (Spec 083 requires tenant entitlement).
- Capability checks for read-only view → rejected (read-only access is entitlement-only; mutations are capability-gated elsewhere).
### Decision 4 — Remove cross-plane tenant fallback (`Tenant::current()`) from this surface
- **Chosen**: `TenantRequiredPermissions::resolveScopedTenant()` must be strict: resolve only from route parameter `{tenant}` (external_id or bound model). If absent/invalid → 404.
- **Rationale**: `Tenant::current()` can leak cross-plane context and violates FR-083-007.
- **Alternatives considered**:
- Keep fallback for convenience → rejected (security hardening goal).
### Decision 5 — DB-only render is guaranteed by using stored `tenant_permissions`
- **Chosen**: Continue using `TenantPermissionService::compare(... liveCheck:false ...)` for this page (no Graph calls).
- **Rationale**: With `liveCheck=false`, compare reads stored `tenant_permissions` only.
- **Alternatives considered**:
- Allow live-check on render → rejected (violates FR-083-010).
### Decision 6 — Freshness (“Last refreshed”) comes from `tenant_permissions.last_checked_at`
- **Chosen**: Define page “Last refreshed” as the max timestamp of stored permission checks for the tenant. Stale if missing or older than 30 days.
- **Rationale**: This is already stored in the database and does not require provider calls.
- **Alternatives considered**:
- Use latest verification run timestamps → possible, but increases coupling; not necessary for Spec 083.
### Decision 7 — Summary status logic is centralized in the view-model builder
- **Chosen**: Update `TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus()` so:
- Blocked if any missing application permission (blocker)
- Else Needs attention if any warning exists (missing delegated, error rows folded into warning, or stale freshness)
- Else Ready
- **Rationale**: Aligns with Spec 083 summary rules and keeps mapping centralized.
- **Alternatives considered**:
- Compute in Blade view → rejected (harder to test, risks drift).
### Decision 8 — “Re-run verification” CTA links to `/admin/onboarding` (“Start verification” surface)
- **Chosen**: Link-only CTA points to the existing onboarding wizard page (admin panel slug `onboarding`).
- **Rationale**: Clarification outcome; capability gating occurs on the start/execute surface, not on this read-only page.
- **Alternatives considered**:
- Link to provider connection edit → rejected (not the requested primary action).
## Open questions
None remaining for Spec 083 (clarifications already settled).

View File

@ -0,0 +1,214 @@
# Feature Specification: Canonical Required Permissions (Manage) Hardening & Enterprise UX
**Feature Branch**: `083-required-permissions-hardening`
**Created**: 2026-02-08
**Status**: Ready for implementation
**Input**: User description: "Harden the canonical Required Permissions manage surface: enforce tenant entitlement, keep legacy URL non-existent (404), remove cross-plane fallbacks, and improve issues-first UX without any provider calls."
## Clarifications
### Session 2026-02-08
- Q: Soll die optionale Workspace-weite Ausnahme „alle Tenants ansehen“ (ohne TenantMembership) Teil von Spec 083 sein? → A: Nein. Spec 083 basiert ausschließlich auf Tenant-Entitlement; kein „view all tenants“ Override.
- Q: Wie genau soll die Summary-Status-Logik (Blocked / Needs attention / Ready) definiert werden? → A: Blocked wenn mind. 1 Blocker; sonst Needs attention wenn mind. 1 Warning (inkl. stale); sonst Ready.
- Q: Ab wann gilt „Freshness“ als „stale“ (Warning)? → A: Warnung, wenn „Last refreshed“ fehlt oder älter als 30 Tage ist.
- Q: Soll die Seite einen expliziten „Error“-Issue-Typ anzeigen, oder nur Blocker/Warnings basierend auf gespeicherten Permission-Daten? → A: Kein „Error“-Issue-Typ in Spec 083. Nur Blocker (missing application) + Warnings (delegated/stale/unknown).
- Q: Wohin soll der links-only CTA „Re-run verification“ canonical führen? → A: Zur „Start verification“ Surface (Wizard/Startseite), damit ein neuer Run gestartet werden kann (capability-gated dort).
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Required Permissions sicher ansehen (Priority: P1)
Als Workspace-Mitglied mit Tenant-Entitlement möchte ich die "Required Permissions" Seite eines Tenants öffnen, um sofort zu erkennen, ob administrative Berechtigungen fehlen (Blocker) oder ob nur Hinweise/Warnings bestehen — ohne dass dadurch externe Provider-Aufrufe ausgelöst werden.
**Why this priority**: Das ist die primäre, risikorelevante Enterprise-UX: Security- und Operations-Teams müssen schnell und sicher einschätzen können, ob Handlungsbedarf besteht.
**Independent Test**: Kann vollständig über einen einzelnen GET-Aufruf auf die Canonical-URL getestet werden, inklusive 200/404 Semantik, UI-Sektionen und „keine externen Calls“.
**Acceptance Scenarios**:
1. **Given** ein User ist Workspace-Mitglied und tenant-entitled, **When** er die Canonical-URL für den Tenant öffnet, **Then** erhält er 200 und sieht eine issues-first Zusammenfassung (Summary → Issues → Passed → Technical).
2. **Given** die Seite wird aufgerufen, **When** sie gerendert wird, **Then** werden keine externen Provider-Anfragen ausgelöst (nur gespeicherte Daten werden verwendet).
---
### User Story 2 - Next steps finden, ohne Mutationsrechte zu benötigen (Priority: P2)
Als tenant-entitled User möchte ich auf der Seite klare "Next steps" sehen (links-only), um fehlende Berechtigungen zu beheben oder eine erneute Verifikation anzustoßen, ohne dass ich selbst zwingend Mutationsrechte habe.
**Why this priority**: In Enterprise-Umgebungen sind Rollen getrennt: Viewer müssen Probleme erkennen und korrekt eskalieren können, ohne selbst Änderungen durchführen zu dürfen.
**Independent Test**: Kann über Render-Assertions getestet werden: Issue-Karten enthalten ausschließlich Links zu passenden Folgeseiten, und die Links sind canonical.
**Acceptance Scenarios**:
1. **Given** es existieren Blocker/Warnings, **When** die Seite gerendert wird, **Then** enthält jede Issue eine klare, links-only Handlungsempfehlung (z.B. „Admin consent dokumentieren“, „Verifikation erneut starten“, „Provider-Verbindung verwalten“).
2. **Given** Next-step Links werden angezeigt, **When** die URLs geprüft werden, **Then** verweisen sie auf die canonical Manage-Surfaces und nicht auf Legacy-Tenant-Plane URLs.
---
### User Story 3 - Tenant-Discovery verhindern (Deny-as-not-found) (Priority: P3)
Als Security Owner möchte ich, dass Workspace-Mitglieder ohne Tenant-Entitlement weder über URL-Varianten noch über Fehlermeldungen Hinweise auf die Existenz eines Tenants oder dessen Security-Posture erhalten.
**Why this priority**: Verhindert Tenant-Leakage und erzwingt eine konsistente Enterprise-Sicherheitsposition.
**Independent Test**: Kann isoliert über negative Access-Tests (404 Semantik) für verschiedene Benutzerzustände getestet werden.
**Acceptance Scenarios**:
1. **Given** ein User ist Workspace-Mitglied ohne Tenant-Entitlement, **When** er die canonical Required-Permissions URL eines Tenants aufruft, **Then** erhält er 404 (deny-as-not-found).
2. **Given** ein User ruft eine Legacy-Tenant-Plane URL-Variante auf, **When** der Request verarbeitet wird, **Then** ist das Ergebnis 404 (keine Redirects, keine Aliases).
---
### Edge Cases
- Tenant-ID ist syntaktisch ungültig oder verweist auf keinen Tenant → 404.
- Tenant gehört nicht zum aktuell selektierten Workspace → 404.
- Workspace ist nicht selektiert / User ist kein Workspace-Mitglied → 404.
- Es existieren keine gespeicherten Daten (noch nie verifiziert / gelöscht) → Seite erklärt „keine Daten verfügbar“ und verlinkt zur Verifikation.
- Daten sind alt (stale) → Warning + Link zu „erneut verifizieren“.
- Freshness ist unbekannt (kein „Last refreshed“) → Warning + Link zu „erneut verifizieren“.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
#### Surfaces & Routing
- **FR-083-001**: Die Required-Permissions Oberfläche MUSS ausschließlich auf der canonical Manage-URL verfügbar sein: `GET /admin/tenants/{tenant}/required-permissions`.
- **FR-083-002**: Eine Legacy-Tenant-Plane Variante MUSS nicht existieren und MUSS 404 liefern: `GET /admin/t/{tenant}/required-permissions` (keine Redirects, keine Aliases).
#### Authorization (Enterprise Hardening)
- **FR-083-003**: Die Seite MUSS deny-as-not-found (404) verwenden, wenn der User kein Workspace-Mitglied ist.
- **FR-083-004**: Die Seite MUSS deny-as-not-found (404) verwenden, wenn der User Workspace-Mitglied ist, aber kein Tenant-Entitlement besitzt.
- **FR-083-005**: Die Seite MUSS 200 liefern, wenn der User Workspace-Mitglied ist und Tenant-Entitlement besitzt (inkl. Readonly-Entitlement).
- **FR-083-006**: Der Route-Parameter `{tenant}` MUSS vorhanden sein und einem Tenant im aktuell selektierten Workspace entsprechen; fehlt der Parameter oder ist er ungültig, MUSS 404 zurückgegeben werden.
- **FR-083-007**: Die Seite MUSS strikt an den URL-Tenant gebunden sein; es darf keinen impliziten Fallback auf einen „aktuellen“ Tenant-Kontext geben.
#### 404 vs 403 Semantik (RBAC-UX)
- **FR-083-008**: 404-Antworten bei Membership-/Entitlement-Denial MÜSSEN generisch bleiben und dürfen keinen Ablehnungsgrund offenlegen (kein Tenant-Leakage).
- **FR-083-009**: Falls auf der Seite Aktionen/Mutations verlinkt werden (z.B. „Verifikation starten“), MUSS die eigentliche Mutation server-seitig capability-gated sein und bei fehlender Fähigkeit 403 liefern. Die Required-Permissions Seite selbst bleibt read-only; die 403-Durchsetzung wird auf den Ziel-Surfaces umgesetzt (kein zusätzlicher Mutations-Endpunkt in Spec 083).
#### Data Source & External Calls
- **FR-083-010**: Das Anzeigen der Seite MUSS ausschließlich gespeicherte Daten verwenden und darf keine externen Provider-Aufrufe auslösen.
#### UX (Issues-first)
- **FR-083-011**: Die Seite MUSS oben eine Summary zeigen, die die Gesamtlage verständlich einordnet (z.B. „Blocked / Needs attention / Ready“) und die wichtigsten Counts enthält.
- **FR-083-011a**: Die Summary-Status-Logik MUSS eindeutig sein: **Blocked** wenn mindestens ein Blocker vorliegt; sonst **Needs attention** wenn mindestens ein Warning vorliegt (inkl. „stale“); sonst **Ready**.
- **FR-083-012**: Die Seite MUSS prominent eine Issues-Sektion bereitstellen, die Blocker (fehlende Application-Berechtigungen) und Warnings (z.B. delegated gaps, stale data) priorisiert.
- **FR-083-012a**: Die Issues-Sektion MUSS sich in Spec 083 auf **Blocker** und **Warnings** beschränken; ein separater „Error“-Issue-Typ ist nicht Teil des Umfangs.
- **FR-083-013**: Jede Issue MUSS links-only Next steps enthalten (keine eingebetteten Mutations) und klar zwischen „Beheben“ und „erneut verifizieren“ unterscheiden.
- **FR-083-013a**: Der links-only CTA „Re-run verification“ MUSS canonical zur „Start verification“ Surface `/admin/onboarding` führen und über zentrale Route-Generierung erstellt werden (kein hardcodierter Legacy-Pfad). Die capability-basierte Durchsetzung (403) erfolgt dort, nicht auf der Required-Permissions Seite.
- **FR-083-014**: Die Seite MUSS einen Hinweis enthalten, dass die Anzeige auf gespeicherten Daten basiert, inkl. Freshness/Last refreshed Information, sofern aus gespeicherten Daten ableitbar.
- **FR-083-014a**: Freshness MUSS als Warning gelten, wenn „Last refreshed“ fehlt oder älter als 30 Tage ist.
- **FR-083-014b**: Wenn keine gespeicherten Permission-Daten vorhanden sind, MUSS die Seite einen klaren Empty State („Keine Daten verfügbar“) rendern und einen links-only CTA zur Start-verification Surface anzeigen.
- **FR-083-015**: „Technical details“ MUSS verfügbar sein, aber nachrangig: die Sektion MUSS nach „Issues“ und „Passed“ erscheinen und standardmäßig eingeklappt sein.
#### Link Consistency
- **FR-083-016**: In-App Links zur Required-Permissions Oberfläche MÜSSEN canonical sein und konsistent generiert werden (keine hardcodierten Legacy-Pfade).
#### Dependencies & Assumptions
- **FR-083-017**: Die Seite baut auf existierenden Manage-Surfaces für Tenants, Verifikation und Provider-Verbindungen auf (nur Verlinkung; keine neue Surface wird dadurch eingeführt).
- **FR-083-018**: Es existiert ein Konzept von Workspace-Mitgliedschaft und Tenant-Entitlement; Entitlement ist die Voraussetzung für read-only Zugriff.
#### Test Requirements (Mandatory)
- **T-083-001**: Kein Workspace-Mitglied → 404.
- **T-083-002**: Workspace-Mitglied ohne Tenant-Entitlement → 404.
- **T-083-003**: Tenant-entitled User (Readonly) → 200.
- **T-083-004**: Keine gespeicherten Daten → Seite zeigt „Keine Daten verfügbar“ und einen canonical CTA zur Start-verification Surface.
- **T-083-005**: DB-only Render: canonical URL rendert ohne externe Provider-Requests und ohne Hintergrundarbeit auszulösen.
- **T-083-006**: Legacy URL bleibt 404: `/admin/t/{tenant}/required-permissions`.
- **T-083-007**: Link canonicalization: Next steps enthalten ausschließlich canonical Manage-Links.
- **T-083-008**: Cross-plane fallback Regression: Aufruf ohne gültigen Route-Tenant darf keinen impliziten „aktuellen Tenant“ nutzen → 404.
- **T-083-009**: Summary-Status-Logik: Blocker → „Blocked“; nur Warnings/Stale → „Needs attention“; keine Issues → „Ready“.
- **T-083-010**: Stale-Threshold: „Last refreshed“ fehlt oder älter als 30 Tage → Warning; jünger/gleich 30 Tage → kein Freshness-Warning.
- **T-083-011**: Issues-Typen: Seite zeigt keine separate „Error“-Issue-Kategorie (nur Blocker + Warnings).
- **T-083-012**: „Re-run verification“ Link führt canonical zur „Start verification“ Surface (kein Link auf „latest report“ als Primärziel).
- **T-083-013**: „Technical details“ ist standardmäßig eingeklappt und erscheint nach „Issues“ und „Passed“.
## UI Action Matrix *(mandatory when Filament is changed)*
Für jede betroffene UI-Oberfläche: liste die sichtbaren Actions/CTAs, ob sie destruktiv sind (Bestätigung erforderlich),
welche Autorisierung gilt (Entitlement vs. Fähigkeit für Mutationen), und ob ein Audit-Eintrag erwartet wird.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|
| Admin Page: Required Permissions | Admin → Tenants → Required permissions | None (read-only) | N/A | None | None | Links-only: “Start verification”, “Manage provider connection” | N/A | N/A | No (view-only) | Verlinkte Mutations/Aktionen liegen auf anderen Surfaces und müssen dort 403/capability-gated sein |
### Key Entities *(include if feature involves data)*
- **Workspace**: Sicherheits- und Sichtbarkeitsgrenze; ein User muss Mitglied sein, um Tenant-Surfaces überhaupt sehen zu können.
- **Tenant**: Mandant im Workspace; Required Permissions sind tenant-spezifisch.
- **Workspace Membership**: Belegt, dass ein User zum Workspace gehört.
- **Tenant Entitlement (Tenant Membership)**: Belegt, dass ein User in diesem Tenant lesen darf (inkl. Readonly).
- **Permission Inventory Snapshot**: Gespeicherte Datenbasis, aus der Required-Permissions Status/Issues abgeleitet werden.
- **Verification Evidence / Report**: Gespeicherte Ergebnisse, die Freshness/Last refreshed und Issues erklären und auf „erneut verifizieren“ verlinken.
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-083-001**: 100% der Requests von nicht-entitled Workspace-Mitgliedern auf die Required-Permissions Seite enden in 404 (kein Tenant-Leakage über Statuscodes).
- **SC-083-002**: 100% der Requests auf die Legacy-URL-Variante `/admin/t/{tenant}/required-permissions` enden in 404 (keine Redirects).
- **SC-083-003**: Beim Anzeigen der Seite werden 0 externe Provider-Anfragen ausgelöst (verifizierbar über Tests/Instrumentation).
- **SC-083-004**: Tenant-entitled Nutzer können in ≤ 30 Sekunden mindestens einen Blocker identifizieren und den passenden Next-step Link finden (Usability/UX-Verifikation).
- **SC-083-005**: In Staging liegt für `GET /admin/tenants/{tenant}/required-permissions` bei einer typischen Tenant-Datenmenge (mindestens 200 gespeicherte Permission-Zeilen) die p95-Server-Antwortzeit bei ≤ 500 ms (DB-only, ohne externe Provider-Calls).

View File

@ -0,0 +1,162 @@
---
description: "Task list for Spec 083-required-permissions-hardening"
---
# Tasks: 083-required-permissions-hardening
**Input**: Design documents from `/specs/083-required-permissions-hardening/`
- Spec: [spec.md](spec.md)
- Plan: [plan.md](plan.md)
- Research: [research.md](research.md)
- Data model: [data-model.md](data-model.md)
- Contracts: [contracts/routes.md](contracts/routes.md)
- Quickstart: [quickstart.md](quickstart.md)
**Tests**: REQUIRED (Pest) — runtime behavior changes.
## Phase 1: Setup (Shared Infrastructure)
- [X] T001 Run prerequisites check via .specify/scripts/bash/check-prerequisites.sh --json
- [X] T002 Ensure agent context is up to date via .specify/scripts/bash/update-agent-context.sh copilot
- [X] T003 [P] Create feature test directory tests/Feature/RequiredPermissions/ (add .gitkeep if needed)
---
## Phase 2: Foundational (Blocking Prerequisites)
- [X] T004 Review current canonical page implementation in app/Filament/Pages/TenantRequiredPermissions.php (identify tenant fallback + current access checks)
- [X] T005 [P] Review existing DB-only render guard patterns in tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php (copy the Http::preventStrayRequests() approach)
- [X] T006 [P] Review existing cross-plane 404 patterns in tests/Feature/Auth/CrossScopeAccessTest.php (align with 404 semantics)
- [X] T007 [P] Confirm factories exist for required models (Workspace, WorkspaceMembership, Tenant, TenantMembership, TenantPermission, User) under database/factories/
**Checkpoint**: Foundational ready — implement US1/US2/US3.
---
## Phase 3: User Story 1 — Required Permissions sicher ansehen (Priority: P1) 🎯 MVP
**Goal**: Canonical manage surface renders issues-first from DB-only state with correct 200/404 semantics.
**Independent Test**: A single GET to `/admin/tenants/{external_id}/required-permissions` returns 200 for tenant-entitled users and triggers no outbound HTTP.
### Tests (US1)
- [X] T008 [P] [US1] Add DB-only render test in tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php
- [X] T009 [P] [US1] Add happy-path entitlement test (tenant-entitled → 200) in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
- [X] T030 [P] [US1] Add empty-data state test ("Keine Daten verfügbar" + Start verification CTA) in tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php
- [X] T031 [P] [US1] Add test that "Technical details" is rendered after Issues/Passed and is collapsed by default in tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
### Implementation (US1)
- [X] T010 [US1] Enforce explicit 404 denial rules on page entry in app/Filament/Pages/TenantRequiredPermissions.php (workspace selected, tenant in workspace, workspace member, tenant-entitled)
- [X] T011 [US1] Remove cross-plane fallback by making resolveScopedTenant() strict (no Tenant::current()) in app/Filament/Pages/TenantRequiredPermissions.php
- [X] T012 [US1] Add freshness derivation (last_refreshed_at, is_stale) based on tenant_permissions.last_checked_at in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [X] T013 [US1] Update summary overall status derivation to treat stale freshness as a warning (Blocked > Needs attention > Ready) in app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- [X] T014 [US1] Render Summary → Issues → Passed → Technical layout (issues-first) using viewModel fields in resources/views/filament/pages/tenant-required-permissions.blade.php
- [X] T032 [US1] Render explicit empty-data state and keep "Technical details" collapsed by default in resources/views/filament/pages/tenant-required-permissions.blade.php
---
## Phase 4: User Story 2 — Next steps finden, ohne Mutationsrechte zu benötigen (Priority: P2)
**Goal**: Each issue includes link-only next steps that point to canonical manage surfaces; re-run verification links to Start verification.
**Independent Test**: Page renders next-step links that are canonical and the “Re-run verification” CTA points to `/admin/onboarding`.
### Tests (US2)
- [X] T015 [P] [US2] Add CTA/link assertion test for re-run verification pointing to /admin/onboarding in tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
- [X] T016 [P] [US2] Add test asserting no legacy tenant-plane links are emitted (no /admin/t/...) in tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
### Implementation (US2)
- [X] T017 [US2] Change reRunVerificationUrl() to return the canonical Start verification surface via route helper (target: /admin/onboarding) in app/Filament/Pages/TenantRequiredPermissions.php
- [X] T018 [US2] Ensure issue cards only contain link-only next steps and canonical manage URLs in resources/views/filament/pages/tenant-required-permissions.blade.php
---
## Phase 5: User Story 3 — Tenant-Discovery verhindern (Deny-as-not-found) (Priority: P3)
**Goal**: Non-entitled users cannot discover tenant existence/posture via status codes or legacy routes.
**Independent Test**: Requests for non-members/non-entitled return 404, and legacy `/admin/t/{tenant}/required-permissions` is 404.
### Tests (US3)
- [X] T019 [P] [US3] Add test: workspace-member but not tenant-entitled → 404 in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
- [X] T020 [P] [US3] Add test: not a workspace member → 404 in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
- [X] T021 [P] [US3] Add test: legacy /admin/t/{tenant}/required-permissions returns 404 in tests/Feature/RequiredPermissions/RequiredPermissionsLegacyRouteTest.php
- [X] T022 [P] [US3] Add regression test: route tenant invalid does not fall back to a current tenant context (still 404) in tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
### Implementation (US3)
- [X] T023 [US3] Ensure all deny-as-not-found conditions abort(404) (not 403) in app/Filament/Pages/TenantRequiredPermissions.php
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T024 [P] Update existing unit coverage for overall status if signature/logic changed in tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
- [X] T025 [P] Add new unit tests for freshness/stale threshold (missing or >30 days) in tests/Unit/TenantRequiredPermissionsFreshnessTest.php
- [X] T026 Run formatting via vendor/bin/sail bin pint --dirty
- [X] T027 Run targeted tests via vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
- [X] T028 Run targeted unit tests via vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissions
- [X] T029 Validate quickstart steps remain accurate in specs/083-required-permissions-hardening/quickstart.md
---
## Dependencies & Execution Order
### User Story completion order
```mermaid
graph TD
P1[US1: View canonical page safely] --> P2[US2: Canonical next steps links]
P1 --> P3[US3: Deny-as-not-found + legacy 404]
P2 --> Polish[Polish & regression coverage]
P3 --> Polish
```
- Setup (T001T003) → Foundational (T004T007) → US1 (T008T014, T030T032) → US2 (T015T018) + US3 (T019T023) → Polish (T024T029)
### Parallel opportunities
- Phase 1: T003 can run in parallel.
- Phase 2: T005T007 are parallel.
- US1 tests (T008T009, T030T031) can be written in parallel.
- US2 tests (T015T016) can be written in parallel.
- US3 tests (T019T022) can be written in parallel.
- Polish: T024T025 are parallel; T026T028 are sequential validation.
---
## Parallel execution examples (per story)
### US1
- Run in parallel:
- T008: tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php
- T009: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php
- T030: tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php
- T031: tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php
### US2
- Run in parallel:
- T015: tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php (CTA)
- T016: tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php (no legacy links)
### US3
- Run in parallel:
- T019: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php (non-entitled 404)
- T020: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php (non-member 404)
- T021: tests/Feature/RequiredPermissions/RequiredPermissionsLegacyRouteTest.php
- T022: tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php (no fallback)
---
## Task completeness validation
- Every user story has:
- At least one independently runnable verification test task
- Implementation tasks with concrete file paths
- A clear checkpoint goal and independent test criteria

View File

@ -13,6 +13,7 @@
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -98,6 +99,41 @@
->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
}); });
test('sync groups action is forbidden for readonly members when disabled check is bypassed', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListEntraGroupSyncRuns::class)->instance();
$action = $component->getAction([['name' => 'sync_groups']]);
expect($action)->not->toBeNull();
$thrown = null;
try {
$action->callBefore();
$action->call();
} catch (HttpException $exception) {
$thrown = $exception;
}
expect($thrown)->not->toBeNull();
expect($thrown?->getStatusCode())->toBe(403);
Queue::assertNothingPushed();
$runCount = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
expect($runCount)->toBe(0);
});
test('sync groups action is disabled for readonly users with standard tooltip', function () { test('sync groups action is disabled for readonly users with standard tooltip', function () {
Queue::fake(); Queue::fake();

View File

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\InventorySyncRunResource;
use App\Filament\Resources\InventorySyncRunResource\Pages\ListInventorySyncRuns;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\SyncPoliciesJob;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('passes the action surface contract guard for current repository state', function (): void {
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('excludes widgets from action surface discovery scope', function (): void {
$classes = array_map(
static fn ($component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
);
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
return str_starts_with($className, 'App\\Filament\\Widgets\\');
}));
expect($widgetClasses)->toBeEmpty();
});
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
$exemptions = ActionSurfaceExemptions::baseline();
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
});
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
expect($tenantResource)->not->toBeNull();
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
expect($policyResource)->not->toBeNull();
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
});
it('requires non-empty reasons for every baseline exemption', function (): void {
$reasons = ActionSurfaceExemptions::baseline()->all();
foreach ($reasons as $className => $reason) {
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
}
});
it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition;
$declarations = [
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
];
foreach ($declarations as $className => $declaration) {
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
expect($declaration->slot($slot))
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
}
}
});
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync']);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
expect($rowGroup?->getLabel())->toBe('More');
$primaryRowActionCount = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->count();
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$bulkActions = $table->getBulkActions();
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
expect($bulkGroup?->getLabel())->toBe('More');
});
it('uses canonical tenantless View run links on representative operation links', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
]);
expect(OperationRunLinks::view($run, $tenant))
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
});
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventoryItems::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($item);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
});
it('removes lone View buttons and uses clickable rows on the inventory sync runs list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventorySyncRuns::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($run);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventorySyncRunResource::getUrl('view', ['record' => $run]));
});
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
expect((string) $run?->initiator_name)->toBe((string) $user->name);
});

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceSlotRequirement;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidatorCompleteStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorMissingSlotStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
}
}
final class ActionSurfaceValidatorRunLogNoExportStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorNoDeclarationStub {}
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
{
return new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Resource,
panelScopes: [ActionSurfacePanelScope::Tenant],
);
}
it('passes when all required slots are declared', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorCompleteStub::class)]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('fails when a required slot is missing', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSlotStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Required slot is not declared');
});
it('fails missing declarations when no baseline exemption exists', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
});
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([
ActionSurfaceValidatorNoDeclarationStub::class => 'Retrofit intentionally deferred in validator unit test.',
]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('requires a bulk exemption reason when run-log export default is disabled', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorRunLogNoExportStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('ReadOnly/RunLog profile disables Export default');
});
it('fails when slot exemption reason is missing', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorExemptSlotWithoutReasonStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('returns 404 for non-members on representative action-surface route', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $workspaceB->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('returns 200 for tenant-entitled readonly members on the canonical required permissions route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertOk();
});
it('returns 404 for workspace members without tenant entitlement on the canonical route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertNotFound();
});
it('returns 404 for users who are not workspace members', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertNotFound();
});
it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
Tenant::query()->whereKey((int) $tenant->getKey())->update(['is_current' => true]);
$this->actingAs($user)
->get('/admin/tenants/invalid-tenant-id/required-permissions')
->assertNotFound();
});

View File

@ -13,7 +13,7 @@
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($user) $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('Guidance') ->assertSee('Guidance')
->assertSee('Who can fix this?', false) ->assertSee('Who can fix this?', false)

View File

@ -1,13 +1,21 @@
<?php <?php
it('renders the required permissions page without Graph or outbound HTTP calls', function (): void { declare(strict_types=1);
use Illuminate\Support\Facades\Queue;
it('renders the canonical required permissions page without Graph, outbound HTTP, or queue dispatches', function (): void {
bindFailHardGraphClient(); bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
Queue::fake();
assertNoOutboundHttp(function () use ($user, $tenant): void { assertNoOutboundHttp(function () use ($user, $tenant): void {
$this->actingAs($user) $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful(); ->assertSuccessful();
}); });
Queue::assertNothingPushed();
}); });

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Keine Daten verfügbar')
->assertSee('/admin/onboarding', false)
->assertSee('Start verification');
});

View File

@ -51,7 +51,7 @@
]); ]);
$missingResponse = $this->actingAs($user) $missingResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('All required permissions are present', false); ->assertSee('All required permissions are present', false);
@ -61,7 +61,7 @@
->assertDontSee('data-permission-key="Gamma.Manage.All"', false); ->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
$presentResponse = $this->actingAs($user) $presentResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present")
->assertSuccessful() ->assertSuccessful()
->assertSee('wire:model.live="status"', false); ->assertSee('wire:model.live="status"', false);
@ -71,7 +71,7 @@
->assertSee('data-permission-key="Gamma.Manage.All"', false); ->assertSee('data-permission-key="Gamma.Manage.All"', false);
$delegatedResponse = $this->actingAs($user) $delegatedResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present&type=delegated") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present&type=delegated")
->assertSuccessful(); ->assertSuccessful();
$delegatedResponse $delegatedResponse
@ -85,7 +85,7 @@
]); ]);
$featureResponse = $this->actingAs($user) $featureResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?{$featureQuery}") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
->assertSuccessful(); ->assertSuccessful();
$featureResponse $featureResponse
@ -94,7 +94,7 @@
->assertDontSee('data-permission-key="Beta.Read.All"', false); ->assertDontSee('data-permission-key="Beta.Read.All"', false);
$searchResponse = $this->actingAs($user) $searchResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?status=all&search=delegated") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all&search=delegated")
->assertSuccessful(); ->assertSuccessful();
$searchResponse $searchResponse

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
it('returns 404 for the legacy tenant-plane required permissions route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->assertNotFound();
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Re-run verification')
->assertSee('/admin/onboarding', false)
->assertDontSee('/admin/t/', false);
});
it('renders sections in summary-issues-passed-technical order and keeps technical details collapsed by default', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details'])
->assertSee('<details data-testid="technical-details"', false)
->assertDontSee('data-testid="technical-details" open', false);
});

View File

@ -26,7 +26,7 @@
]); ]);
$this->actingAs($user) $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('Blocked', false) ->assertSee('Blocked', false)
->assertSee('applyFeatureFilter', false) ->assertSee('applyFeatureFilter', false)

View File

@ -1,33 +0,0 @@
<?php
use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
it('returns 404 for non-members accessing required permissions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$this->actingAs($user)
->get("/admin/t/{$otherTenant->external_id}/required-permissions")
->assertNotFound();
});
it('returns 403 for members without tenant.view capability accessing required permissions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->mock(CapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('isMember')
->andReturn(true);
$mock->shouldReceive('can')
->andReturnUsing(fn ($user, $tenant, $capability): bool => $capability !== Capabilities::TENANT_VIEW);
});
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->assertForbidden();
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use Carbon\CarbonImmutable;
it('marks freshness as stale when last refreshed is missing', function (): void {
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
null,
CarbonImmutable::parse('2026-02-08 12:00:00'),
);
expect($freshness['last_refreshed_at'])->toBeNull()
->and($freshness['is_stale'])->toBeTrue();
});
it('marks freshness as stale when last refreshed is older than 30 days', function (): void {
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
CarbonImmutable::parse('2026-01-08 11:59:59'),
CarbonImmutable::parse('2026-02-08 12:00:00'),
);
expect($freshness['is_stale'])->toBeTrue();
});
it('marks freshness as not stale when last refreshed is exactly 30 days old', function (): void {
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
CarbonImmutable::parse('2026-01-09 12:00:00'),
CarbonImmutable::parse('2026-02-08 12:00:00'),
);
expect($freshness['is_stale'])->toBeFalse();
});

View File

@ -98,3 +98,27 @@
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows)) expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
->toBe(VerificationReportOverall::Ready->value); ->toBe(VerificationReportOverall::Ready->value);
}); });
it('maps overall to needs_attention when freshness is stale without explicit permission gaps', function (): void {
$rows = [
[
'key' => 'A',
'type' => 'application',
'description' => null,
'features' => ['backup'],
'status' => 'granted',
'details' => null,
],
[
'key' => 'B',
'type' => 'delegated',
'description' => null,
'features' => ['backup'],
'status' => 'granted',
'details' => null,
],
];
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows, true))
->toBe(VerificationReportOverall::NeedsAttention->value);
});