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)
- 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.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -40,9 +41,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## 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
- 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 -->

View File

@ -1,11 +1,10 @@
<!--
Sync Impact Report
- Version change: 1.6.0 → 1.7.0
- Version change: 1.7.0 → 1.8.0
- Modified principles:
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate)
- Added sections:
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule)
- Added sections: None
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
@ -144,6 +143,9 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
Required surfaces
- 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).
- Create/Edit MUST provide consistent Save/Cancel UX.
@ -198,4 +200,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **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
- 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, 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

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?),
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)*

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:
- 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),
- 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,
- grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale),

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
@ -41,34 +40,28 @@ class TenantRequiredPermissions extends Page
*/
public array $viewModel = [];
public ?Tenant $scopedTenant = null;
public static function canAccess(): bool
{
$tenant = 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();
return static::hasScopedTenantAccess(static::resolveScopedTenant());
}
public function currentTenant(): ?Tenant
{
return static::resolveScopedTenant();
return $this->scopedTenant;
}
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);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
@ -147,7 +140,7 @@ public function resetFilters(): void
private function refreshViewModel(): void
{
$tenant = static::resolveScopedTenant();
$tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) {
$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) {
return null;
}
$connectionId = ProviderConnection::query()
->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');
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant
@ -209,6 +197,32 @@ protected static function resolveScopedTenant(): ?Tenant
->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\Support\Badges\BadgeDomain;
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 Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
@ -32,6 +35,16 @@ class EntraGroupResource extends Resource
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
{
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));
})
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->columns([
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(),
Tables\Columns\TextColumn::make('display_name')
->label('Name')
->searchable(),
Tables\Columns\TextColumn::make('entra_id')
->label('Entra ID')
->copyable()
->toggleable(),
Tables\Columns\TextColumn::make('type')
->label('Type')
->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(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([
SelectFilter::make('stale')
@ -165,9 +188,7 @@ public static function table(Table $table): Table
};
}),
])
->actions([
Actions\ViewAction::make(),
])
->actions([])
->bulkActions([]);
}

View File

@ -8,8 +8,11 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
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 Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
@ -34,6 +37,16 @@ class EntraGroupSyncRunResource extends Resource
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
{
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('error_count')->label('Errors')->numeric(),
])
->actions([
Actions\ViewAction::make(),
])
->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}

View File

@ -17,8 +17,11 @@
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType;
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 Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
@ -42,6 +45,16 @@ class InventoryItemResource extends Resource
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
{
$tenant = Tenant::current();
@ -262,9 +275,10 @@ public static function table(Table $table): Table
->options($categoryOptions)
->searchable(),
])
->actions([
Actions\ViewAction::make(),
])
->recordUrl(static fn (Model $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}

View File

@ -12,8 +12,11 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
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 Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
@ -39,6 +42,16 @@ class InventorySyncRunResource extends Resource
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
{
$tenant = Tenant::current();
@ -189,9 +202,10 @@ public static function table(Table $table): Table
->label('Errors')
->numeric(),
])
->actions([
Actions\ViewAction::make(),
])
->recordUrl(static fn (Model $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
}

View File

@ -16,6 +16,11 @@
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
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 BackedEnum;
use Filament\Actions;
@ -41,12 +46,40 @@ class OperationRunResource extends Resource
protected static bool $shouldRegisterNavigation = false;
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
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
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();

View File

@ -23,6 +23,10 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
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 Filament\Actions;
use Filament\Actions\ActionGroup;
@ -52,6 +56,17 @@ class PolicyResource extends Resource
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
{
return $schema;
@ -536,7 +551,9 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
@ -888,7 +905,7 @@ public static function table(Table $table): Table
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
]),
])->label('More'),
]);
}

View File

@ -22,71 +22,79 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array
{
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();
return [$this->makeSyncAction()];
}
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
protected function getTableEmptyStateActions(): array
{
return [$this->makeSyncAction()];
}
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
private function makeSyncAction(): Actions\Action
{
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 */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
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();
sort($requestedTypes);
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 {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
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();
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->destructive()
->apply(),
];
return;
}
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
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\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
@ -24,6 +28,16 @@ class VersionsRelationManager extends RelationManager
{
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
{
$restoreToIntune = Actions\Action::make('restore_to_intune')

View File

@ -23,6 +23,10 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
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 Carbon\CarbonImmutable;
use Filament\Actions;
@ -51,6 +55,17 @@ class PolicyVersionResource extends Resource
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
{
return $schema
@ -494,8 +509,10 @@ public static function table(Table $table): Table
->trueLabel('All')
->falseLabel('Archived'),
])
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([
Actions\ViewAction::make(),
Actions\ActionGroup::make([
(function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard')

View File

@ -5,6 +5,8 @@
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Services\Graph\GraphClientInterface;
use DateTimeInterface;
use Illuminate\Support\Carbon;
class TenantPermissionService
{
@ -44,6 +46,7 @@ public function getGrantedPermissions(Tenant $tenant): array
* @return array{
* overall_status:string,
* 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}
* }
*/
@ -210,6 +213,7 @@ public function compare(
$payload = [
'overall_status' => $overall,
'permissions' => $results,
'last_refreshed_at' => $this->lastRefreshedAtIso($tenant),
];
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\Support\Verification\VerificationReportOverall;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
class TenantRequiredPermissionsViewModelBuilder
{
@ -16,7 +18,8 @@ class TenantRequiredPermissionsViewModelBuilder
* overview: array{
* overall: string,
* 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>,
* filters: FilterState,
@ -48,6 +51,7 @@ public function build(Tenant $tenant, array $filters = []): array
$state = self::normalizeFilterState($filters);
$filteredPermissions = self::applyFilterState($allPermissions, $state);
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
return [
'tenant' => [
@ -56,9 +60,10 @@ public function build(Tenant $tenant, array $filters = []): array
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => self::deriveOverallStatus($allPermissions),
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($allPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
'freshness' => $freshness,
],
'permissions' => $filteredPermissions,
'filters' => $state,
@ -72,7 +77,7 @@ public function build(Tenant $tenant, array $filters = []): array
/**
* @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(
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',
);
if ($hasErrors || $hasMissingDelegated) {
if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) {
return VerificationReportOverall::NeedsAttention->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
* @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,
];
}
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\BadgeRenderer;
use App\Support\Links\RequiredPermissionsLinks;
use Illuminate\Support\Carbon;
$tenant = $this->currentTenant();
@ -9,6 +10,7 @@
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$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'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
@ -47,17 +49,77 @@
$adminConsentLabel = $adminConsentUrl ? 'Open admin consent' : 'Admin consent guide';
$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
<x-filament::page>
<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-wrap items-start justify-between gap-4">
<div class="space-y-1">
<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.
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}.
</div>
@if ($overallSpec)
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
@ -86,6 +148,16 @@
</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="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
<div class="mt-2 space-y-1">
@ -322,7 +394,75 @@ class="mt-4 space-y-2"
</div>
</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)
<div class="text-sm text-gray-600 dark:text-gray-300">
No tenant selected.
@ -507,6 +647,8 @@ class="align-top"
@endif
</div>
@endif
</div>
</details>
</x-filament::section>
</div>
</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\Queue;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
@ -98,6 +99,41 @@
->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 () {
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');
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Guidance')
->assertSee('Who can fix this?', false)

View File

@ -1,13 +1,21 @@
<?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();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
Queue::fake();
assertNoOutboundHttp(function () use ($user, $tenant): void {
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->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)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('All required permissions are present', false);
@ -61,7 +61,7 @@
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
$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()
->assertSee('wire:model.live="status"', false);
@ -71,7 +71,7 @@
->assertSee('data-permission-key="Gamma.Manage.All"', false);
$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();
$delegatedResponse
@ -85,7 +85,7 @@
]);
$featureResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?{$featureQuery}")
->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
->assertSuccessful();
$featureResponse
@ -94,7 +94,7 @@
->assertDontSee('data-permission-key="Beta.Read.All"', false);
$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();
$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)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Blocked', 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))
->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);
});