TenantAtlas/app/Filament/Resources/BaselineProfileResource.php
ahmido d32b2115a8 Spec 103: IA semantics (scope vs filter vs targeting) + UI polish (#126)
Implements Spec 103 (IA semantics: Scope vs Filter vs Targeting) across Monitoring + Manage.

Changes
- Monitoring tenant indicator copy: “All tenants” / “Filtered by tenant: …”
- Alerts KPI header resolves tenant via OperateHubShell::activeEntitledTenant() for consistency
- Manage list pages (Alert Rules / Destinations) no longer show tenant indicator
- AlertRule form uses targeting semantics + sections (Rule / Applies to / Delivery)
- Additional UI polish: resource sections, tenant view widgets layout, RBAC progressive disclosure (“Not configured” when empty)

Notes
- US6 (“Add current tenant” convenience button) intentionally skipped (optional P3).

Testing
- CI=1 vendor/bin/sail artisan test tests/Feature/TenantRBAC/ tests/Feature/Onboarding/OnboardingIdentifyTenantTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #126
2026-02-21 00:28:15 +00:00

411 lines
15 KiB
PHP

<?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\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
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([
Section::make('Profile')
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description')
->rows(3)
->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'),
TextInput::make('version_label')
->label('Version label')
->maxLength(50)
->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'),
Select::make('status')
->required()
->options([
BaselineProfile::STATUS_DRAFT => 'Draft',
BaselineProfile::STATUS_ACTIVE => 'Active',
BaselineProfile::STATUS_ARCHIVED => 'Archived',
])
->default(BaselineProfile::STATUS_DRAFT)
->native(false)
->helperText('Only active baselines are enforced during compliance checks.'),
])
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
Select::make('scope_jsonb.policy_types')
->label('Policy type scope')
->multiple()
->options(self::policyTypeOptions())
->helperText('Leave empty to include all policy types.')
->native(false),
])
->columnSpanFull(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Profile')
->schema([
TextEntry::make('name'),
TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
TextEntry::make('version_label')
->label('Version')
->placeholder('—'),
TextEntry::make('description')
->placeholder('No description')
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
TextEntry::make('scope_jsonb.policy_types')
->label('Policy type scope')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::policyTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('All policy types'),
])
->columnSpanFull(),
Section::make('Metadata')
->schema([
TextEntry::make('createdByUser.name')
->label('Created by')
->placeholder('—'),
TextEntry::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot yet'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
])
->columns(2)
->columnSpanFull(),
]);
}
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;
}
}