feat: Phase 3 US1 baseline profile CRUD + RBAC + tests (T019-T030)
This commit is contained in:
parent
74ab2d1404
commit
e0800fb42d
341
app/Filament/Resources/BaselineProfileResource.php
Normal file
341
app/Filament/Resources/BaselineProfileResource.php
Normal file
@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
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 App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineProfileResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = BaselineProfile::class;
|
||||
|
||||
protected static ?string $slug = 'baseline-profiles';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Baselines';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->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<string, string>
|
||||
*/
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBaselineProfile extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BaselineProfileResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBaselineProfile extends EditRecord
|
||||
{
|
||||
protected static string $resource = BaselineProfileResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBaselineProfiles extends ListRecords
|
||||
{
|
||||
protected static string $resource = BaselineProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create baseline profile')
|
||||
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBaselineProfile extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BaselineProfileResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make()
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
|
||||
|
||||
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\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BaselineTenantAssignmentsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'tenantAssignments';
|
||||
|
||||
protected static ?string $title = 'Tenant assignments';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->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.');
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
156
tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
Normal file
156
tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
Normal file
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('BaselineProfile RBAC — 404 vs 403 semantics', function () {
|
||||
it('denies non-members accessing the list page', function (): void {
|
||||
$user = User::factory()->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();
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user