feat: add tenant governance aggregate contract and action surface follow-ups #199

Merged
ahmido merged 6 commits from 168-tenant-governance-aggregate-contract into dev 2026-03-29 21:14:18 +00:00
8 changed files with 126 additions and 9 deletions
Showing only changes of commit 1212412db6 - Show all commits

View File

@ -19,6 +19,10 @@
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -52,6 +56,20 @@ class InventoryCoverage extends Page implements HasTable
protected string $view = 'filament.pages.inventory-coverage'; protected string $view = 'filament.pages.inventory-coverage';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory coverage stays read-only and uses KPI widgets instead of header actions.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
}
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
{ {
if (Filament::getCurrentPanel()?->getId() === 'admin') { if (Filament::getCurrentPanel()?->getId() === 'admin') {

View File

@ -14,6 +14,11 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\OperationLifecyclePolicy; use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -50,6 +55,20 @@ class Operations extends Page implements HasForms, HasTable
// Must be non-static // Must be non-static
protected string $view = 'filament.pages.monitoring.operations'; protected string $view = 'filament.pages.monitoring.operations';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve scope context and return navigation for the monitoring operations list.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs remain immutable on the monitoring list and intentionally omit bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operation runs exist for the active workspace scope.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.');
}
public function mount(): void public function mount(): void
{ {
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;

View File

@ -7,6 +7,9 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -27,6 +30,16 @@ class NoAccess extends Page
protected string $view = 'filament.pages.no-access'; protected string $view = 'filament.pages.no-access';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.');
}
/** /**
* @return array<Action> * @return array<Action>
*/ */

View File

@ -25,6 +25,10 @@
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -47,6 +51,20 @@ class TenantlessOperationRunViewer extends Page
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer'; protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->exempt(ActionSurfaceSlot::ListHeader, 'Canonical tenantless run viewing is a detail-only page and does not render list header actions.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The tenantless run viewer is itself the canonical detail destination for a selected operation run.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Detail viewing does not expose bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a selected run detail instead of a list empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Header keeps scope context, back navigation, refresh, related links, and resumable capture actions when applicable.');
}
public OperationRun $run; public OperationRun $run;
/** /**

View File

@ -12,6 +12,9 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -25,6 +28,16 @@ class TenantDiagnostics extends Page
protected string $view = 'filament.pages.tenant-diagnostics'; protected string $view = 'filament.pages.tenant-diagnostics';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
}
public bool $missingOwner = false; public bool $missingOwner = false;
public bool $hasDuplicateMembershipsForCurrentUser = false; public bool $hasDuplicateMembershipsForCurrentUser = false;

View File

@ -10,6 +10,9 @@
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page; use Filament\Pages\Page;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -27,6 +30,16 @@ class TenantRequiredPermissions extends Page
protected string $view = 'filament.pages.tenant-required-permissions'; protected string $view = 'filament.pages.tenant-required-permissions';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Required permissions keeps guidance, copy flows, and filter reset actions inside body sections instead of page header actions.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Required permissions rows are reviewed inline inside the diagnostic matrix and do not open a separate inspect destination.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Permission rows are read-only and do not expose row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Required permissions does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
}
public string $status = 'missing'; public string $status = 'missing';
public string $type = 'all'; public string $type = 'all';

View File

@ -23,15 +23,9 @@ public static function baseline(): self
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.', '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\\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\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.', 'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts remains exempt because the active admin alerts surface resolves through the cluster entry at /admin/alerts, not this page-class route.',
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts 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\\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\\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\\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\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',

View File

@ -161,6 +161,12 @@
$profiles = new ActionSurfaceProfileDefinition; $profiles = new ActionSurfaceProfileDefinition;
$declarations = [ $declarations = [
\App\Filament\Pages\InventoryCoverage::class => \App\Filament\Pages\InventoryCoverage::actionSurfaceDeclaration(),
\App\Filament\Pages\Monitoring\Operations::class => \App\Filament\Pages\Monitoring\Operations::actionSurfaceDeclaration(),
\App\Filament\Pages\NoAccess::class => \App\Filament\Pages\NoAccess::actionSurfaceDeclaration(),
\App\Filament\Pages\Operations\TenantlessOperationRunViewer::class => \App\Filament\Pages\Operations\TenantlessOperationRunViewer::actionSurfaceDeclaration(),
\App\Filament\Pages\TenantDiagnostics::class => \App\Filament\Pages\TenantDiagnostics::actionSurfaceDeclaration(),
\App\Filament\Pages\TenantRequiredPermissions::class => \App\Filament\Pages\TenantRequiredPermissions::actionSurfaceDeclaration(),
AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(), AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(),
BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(), BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(),
BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(), BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(),
@ -234,8 +240,12 @@
it('keeps first-slice trusted-state page action-surface status explicit', function (): void { it('keeps first-slice trusted-state page action-surface status explicit', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline(); $baselineExemptions = ActionSurfaceExemptions::baseline();
expect($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeTrue() expect(method_exists(\App\Filament\Pages\TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toContain('dedicated tests'); ->and($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeFalse();
expect(method_exists(\App\Filament\Pages\Monitoring\Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse()
->and($baselineExemptions->hasClass(\App\Filament\Pages\Monitoring\Alerts::class))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Monitoring\Alerts::class))->toContain('cluster entry');
expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue() expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests'); ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests');
@ -244,6 +254,25 @@
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse(); ->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
}); });
it('keeps cleanup-slice pages declaration-backed without stale baseline exemptions', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline();
foreach ([
\App\Filament\Pages\InventoryCoverage::class,
\App\Filament\Pages\Monitoring\Operations::class,
\App\Filament\Pages\NoAccess::class,
\App\Filament\Pages\Operations\TenantlessOperationRunViewer::class,
\App\Filament\Pages\TenantDiagnostics::class,
\App\Filament\Pages\TenantRequiredPermissions::class,
] as $className) {
expect(method_exists($className, 'actionSurfaceDeclaration'))
->toBeTrue("{$className} should declare its action surface once enrolled.");
expect($baselineExemptions->hasClass($className))
->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment.");
}
});
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void { it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');