diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php new file mode 100644 index 0000000..23ad4b1 --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -0,0 +1,341 @@ +getId() !== 'admin') { + return false; + } + + return parent::shouldRegisterNavigation(); + } + + public static function canViewAny(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = self::resolveWorkspace(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW); + } + + public static function canCreate(): bool + { + return self::hasManageCapability(); + } + + public static function canEdit(Model $record): bool + { + return self::hasManageCapability(); + } + + public static function canDelete(Model $record): bool + { + return self::hasManageCapability(); + } + + public static function canView(Model $record): bool + { + return self::canViewAny(); + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.') + ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.'); + } + + public static function getEloquentQuery(): Builder + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + return parent::getEloquentQuery() + ->with(['activeSnapshot', 'createdByUser']) + ->when( + $workspaceId !== null, + fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId), + ) + ->when( + $workspaceId === null, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ); + } + + public static function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('name') + ->required() + ->maxLength(255), + Textarea::make('description') + ->rows(3) + ->maxLength(1000), + TextInput::make('version_label') + ->label('Version label') + ->maxLength(50), + Select::make('status') + ->required() + ->options([ + BaselineProfile::STATUS_DRAFT => 'Draft', + BaselineProfile::STATUS_ACTIVE => 'Active', + BaselineProfile::STATUS_ARCHIVED => 'Archived', + ]) + ->default(BaselineProfile::STATUS_DRAFT) + ->native(false), + Select::make('scope_jsonb.policy_types') + ->label('Policy type scope') + ->multiple() + ->options(self::policyTypeOptions()) + ->helperText('Leave empty to include all policy types.') + ->native(false), + ]); + } + + public static function table(Table $table): Table + { + $workspace = self::resolveWorkspace(); + + return $table + ->defaultSort('name') + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus)) + ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)) + ->sortable(), + TextColumn::make('version_label') + ->label('Version') + ->placeholder('—'), + TextColumn::make('activeSnapshot.captured_at') + ->label('Last snapshot') + ->dateTime() + ->placeholder('No snapshot'), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->actions([ + Action::make('view') + ->label('View') + ->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record])) + ->icon('heroicon-o-eye'), + ActionGroup::make([ + Action::make('edit') + ->label('Edit') + ->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record])) + ->icon('heroicon-o-pencil-square') + ->visible(fn (): bool => self::hasManageCapability()), + self::archiveTableAction($workspace), + ])->label('More'), + ]) + ->bulkActions([ + BulkActionGroup::make([])->label('More'), + ]) + ->emptyStateHeading('No baseline profiles') + ->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.') + ->emptyStateActions([ + Action::make('create') + ->label('Create baseline profile') + ->url(fn (): string => static::getUrl('create')) + ->icon('heroicon-o-plus') + ->visible(fn (): bool => self::hasManageCapability()), + ]); + } + + public static function getRelations(): array + { + return [ + BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBaselineProfiles::route('/'), + 'create' => Pages\CreateBaselineProfile::route('/create'), + 'view' => Pages\ViewBaselineProfile::route('/{record}'), + 'edit' => Pages\EditBaselineProfile::route('/{record}/edit'), + ]; + } + + /** + * @return array + */ + public static function policyTypeOptions(): array + { + return collect(InventoryPolicyTypeMeta::all()) + ->filter(fn (array $row): bool => filled($row['type'] ?? null)) + ->mapWithKeys(fn (array $row): array => [ + (string) $row['type'] => (string) ($row['label'] ?? $row['type']), + ]) + ->sort() + ->all(); + } + + /** + * @param array $metadata + */ + public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void + { + $workspace = $record->workspace; + + if ($workspace === null) { + return; + } + + $actor = auth()->user(); + + app(WorkspaceAuditLogger::class)->log( + workspace: $workspace, + action: $actionId->value, + context: ['metadata' => $metadata], + actor: $actor instanceof User ? $actor : null, + resourceType: 'baseline_profile', + resourceId: (string) $record->getKey(), + ); + } + + private static function resolveWorkspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return null; + } + + return Workspace::query()->whereKey($workspaceId)->first(); + } + + private static function hasManageCapability(): bool + { + $user = auth()->user(); + $workspace = self::resolveWorkspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } + + private static function archiveTableAction(?Workspace $workspace): Action + { + $action = Action::make('archive') + ->label('Archive') + ->icon('heroicon-o-archive-box') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Archive baseline profile') + ->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.') + ->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability()) + ->action(function (BaselineProfile $record): void { + if (! self::hasManageCapability()) { + throw new AuthorizationException; + } + + $record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save(); + + self::audit($record, AuditActionId::BaselineProfileArchived, [ + 'baseline_profile_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + ]); + + Notification::make() + ->title('Baseline profile archived') + ->success() + ->send(); + }); + + if ($workspace instanceof Workspace) { + $action = WorkspaceUiEnforcement::forTableAction($action, $workspace) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->destructive() + ->apply(); + } + + return $action; + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php new file mode 100644 index 0000000..475259b --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php @@ -0,0 +1,56 @@ + $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $data['workspace_id'] = (int) $workspaceId; + + $user = auth()->user(); + $data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null; + + $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; + $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; + + return $data; + } + + protected function afterCreate(): void + { + $record = $this->record; + + if (! $record instanceof BaselineProfile) { + return; + } + + BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [ + 'baseline_profile_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'status' => (string) $record->status, + ]); + + Notification::make() + ->title('Baseline profile created') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php new file mode 100644 index 0000000..49284ec --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php @@ -0,0 +1,48 @@ + $data + * @return array + */ + protected function mutateFormDataBeforeSave(array $data): array + { + $policyTypes = $data['scope_jsonb']['policy_types'] ?? []; + $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; + + return $data; + } + + protected function afterSave(): void + { + $record = $this->record; + + if (! $record instanceof BaselineProfile) { + return; + } + + BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [ + 'baseline_profile_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'status' => (string) $record->status, + ]); + + Notification::make() + ->title('Baseline profile updated') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php new file mode 100644 index 0000000..7abf445 --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php @@ -0,0 +1,23 @@ +label('Create baseline profile') + ->disabled(fn (): bool => ! BaselineProfileResource::canCreate()), + ]; + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php new file mode 100644 index 0000000..efb3e6d --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -0,0 +1,52 @@ +visible(fn (): bool => $this->hasManageCapability()), + ]; + } + + private function hasManageCapability(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); + } +} diff --git a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php new file mode 100644 index 0000000..302d70d --- /dev/null +++ b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php @@ -0,0 +1,49 @@ +satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.'); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('tenant.display_name') + ->label('Tenant') + ->searchable(), + Tables\Columns\TextColumn::make('assignedByUser.name') + ->label('Assigned by') + ->placeholder('—'), + Tables\Columns\TextColumn::make('created_at') + ->label('Assigned at') + ->dateTime() + ->sortable(), + ]) + ->emptyStateHeading('No tenants assigned') + ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.'); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 5196825..4a2d9be 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -12,6 +12,7 @@ use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertRuleResource; +use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; @@ -151,6 +152,7 @@ public function panel(Panel $panel): Panel AlertRuleResource::class, AlertDeliveryResource::class, WorkspaceResource::class, + BaselineProfileResource::class, ]) ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index 6a10bfb..fd6d8a6 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -46,4 +46,17 @@ enum AuditActionId: string case WorkspaceSettingUpdated = 'workspace_setting.updated'; case WorkspaceSettingReset = 'workspace_setting.reset'; + + case BaselineProfileCreated = 'baseline_profile.created'; + case BaselineProfileUpdated = 'baseline_profile.updated'; + case BaselineProfileArchived = 'baseline_profile.archived'; + case BaselineCaptureStarted = 'baseline_capture.started'; + case BaselineCaptureCompleted = 'baseline_capture.completed'; + case BaselineCaptureFailed = 'baseline_capture.failed'; + case BaselineCompareStarted = 'baseline_compare.started'; + case BaselineCompareCompleted = 'baseline_compare.completed'; + case BaselineCompareFailed = 'baseline_compare.failed'; + case BaselineAssignmentCreated = 'baseline_assignment.created'; + case BaselineAssignmentUpdated = 'baseline_assignment.updated'; + case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; } diff --git a/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php b/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php new file mode 100644 index 0000000..5817486 --- /dev/null +++ b/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php @@ -0,0 +1,156 @@ +create(); + $workspace = Workspace::factory()->create(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $response = $this->actingAs($user) + ->get(BaselineProfileResource::getUrl(panel: 'admin')); + + expect($response->status())->toBeIn([403, 404, 302], 'Non-members should not get HTTP 200'); + }); + + it('returns 404 for members accessing a profile from another workspace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')) + ->assertNotFound(); + }); + + it('returns 200 for readonly members accessing list page', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl(panel: 'admin')) + ->assertOk(); + }); + + it('returns 403 for members with mocked missing capability on list page', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl(panel: 'admin')) + ->assertForbidden(); + }); + + it('returns 403 for readonly members accessing create page', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('create', panel: 'admin')) + ->assertForbidden(); + }); + + it('returns 200 for owner members accessing create page', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('create', panel: 'admin')) + ->assertOk(); + }); + + it('returns 404 for members accessing profile from another workspace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')) + ->assertNotFound(); + }); + + it('returns 403 for readonly members accessing edit page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin')) + ->assertForbidden(); + }); + + it('returns 200 for owner members accessing edit page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $this->actingAs($user) + ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin')) + ->assertOk(); + }); +}); + +describe('BaselineProfile static authorization methods', function () { + it('canViewAny returns false for non-members', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + expect(BaselineProfileResource::canViewAny())->toBeFalse(); + }); + + it('canViewAny returns true for members', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + + expect(BaselineProfileResource::canViewAny())->toBeTrue(); + }); + + it('canCreate returns true for managers and false for readonly', function (): void { + [$owner] = createUserWithTenant(role: 'owner'); + $this->actingAs($owner); + expect(BaselineProfileResource::canCreate())->toBeTrue(); + + [$readonly] = createUserWithTenant(role: 'readonly'); + $this->actingAs($readonly); + expect(BaselineProfileResource::canCreate())->toBeFalse(); + }); +}); diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 5039e17..8189b42 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\OperationRunResource; @@ -73,6 +74,24 @@ } }); +it('discovers the baseline profile resource and validates its declaration', function (): void { + $components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents()) + ->keyBy('className'); + + $baselineResource = $components->get(BaselineProfileResource::class); + + expect($baselineResource)->not->toBeNull('BaselineProfileResource should be discovered by action surface discovery'); + expect($baselineResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue(); + + $declaration = BaselineProfileResource::actionSurfaceDeclaration(); + $profiles = new ActionSurfaceProfileDefinition; + + foreach ($profiles->requiredSlots($declaration->profile) as $slot) { + expect($declaration->slot($slot)) + ->not->toBeNull("Missing required slot {$slot->value} in BaselineProfileResource declaration"); + } +}); + it('ensures representative declarations satisfy required slots', function (): void { $profiles = new ActionSurfaceProfileDefinition; @@ -80,6 +99,7 @@ PolicyResource::class => PolicyResource::actionSurfaceDeclaration(), OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(), VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(), + BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(), ]; foreach ($declarations as $className => $declaration) {