merge: agent session work — Feature 101 complete (Phases 1-6)

This commit is contained in:
Ahmed Darrazi 2026-02-19 15:08:13 +01:00
commit 9085403b9b
55 changed files with 5208 additions and 1 deletions

View File

@ -30,6 +30,7 @@ ## Active Technologies
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -49,7 +50,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 101-golden-master-baseline-governance-v1: Added PHP 8.4.x
- 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BaselineCompareLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Soll vs Ist';
protected static ?int $navigationSort = 30;
protected string $view = 'filament.pages.baseline-compare-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $profileName = null;
public ?int $profileId = null;
public ?int $snapshotId = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
/** @var array<string, int>|null */
public ?array $severityCounts = null;
public static function canAccess(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
$this->state = 'no_tenant';
$this->message = 'No tenant selected.';
return;
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
$this->state = 'no_assignment';
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
return;
}
$profile = $assignment->baselineProfile;
if ($profile === null) {
$this->state = 'no_assignment';
$this->message = 'The assigned baseline profile no longer exists.';
return;
}
$this->profileName = (string) $profile->name;
$this->profileId = (int) $profile->getKey();
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
if ($this->snapshotId === null) {
$this->state = 'no_snapshot';
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
return;
}
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->latest('id')
->first();
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
$this->state = 'comparing';
$this->operationRunId = (int) $latestRun->getKey();
$this->message = 'A baseline comparison is currently in progress.';
return;
}
$scopeKey = 'baseline_profile:' . $profile->getKey();
$findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey);
$totalFindings = (int) (clone $findingsQuery)->count();
if ($totalFindings > 0) {
$this->state = 'ready';
$this->findingsCount = $totalFindings;
$this->severityCounts = [
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
];
if ($latestRun instanceof OperationRun) {
$this->operationRunId = (int) $latestRun->getKey();
}
return;
}
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
$this->state = 'ready';
$this->findingsCount = 0;
$this->operationRunId = (int) $latestRun->getKey();
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
return;
}
$this->state = 'idle';
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->compareNowAction(),
];
}
private function compareNowAction(): Action
{
return Action::make('compareNow')
->label('Compare Now')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Start baseline comparison')
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
->visible(fn (): bool => $this->canCompare())
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
Notification::make()->title('Not authenticated')->danger()->send();
return;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
Notification::make()->title('No tenant context')->danger()->send();
return;
}
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) {
Notification::make()
->title('Cannot start comparison')
->body('Reason: ' . ($result['reason_code'] ?? 'unknown'))
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if ($run instanceof OperationRun) {
$this->operationRunId = (int) $run->getKey();
}
$this->state = 'comparing';
Notification::make()
->title('Baseline comparison started')
->body('A background job will compute drift against the baseline snapshot.')
->success()
->send();
});
}
private function canCompare(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
}
public function getFindingsUrl(): ?string
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl('index', tenant: $tenant);
}
public function getRunUrl(): ?string
{
if ($this->operationRunId === null) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return OperationRunLinks::view($this->operationRunId, $tenant);
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
@ -31,6 +32,7 @@ public function getWidgets(): array
return [
DashboardKpis::class,
NeedsAttention::class,
BaselineCompareNow::class,
RecentDriftFindings::class,
RecentOperations::class,
];

View 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;
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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()),
];
}
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineCaptureService;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
class ViewBaselineProfile extends ViewRecord
{
protected static string $resource = BaselineProfileResource::class;
protected function getHeaderActions(): array
{
return [
$this->captureAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
];
}
private function captureAction(): Action
{
return Action::make('capture')
->label('Capture Snapshot')
->icon('heroicon-o-camera')
->color('primary')
->visible(fn (): bool => $this->hasManageCapability())
->disabled(fn (): bool => ! $this->hasManageCapability())
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
->requiresConfirmation()
->modalHeading('Capture Baseline Snapshot')
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
->form([
Select::make('source_tenant_id')
->label('Source Tenant')
->options(fn (): array => $this->getWorkspaceTenantOptions())
->required()
->searchable(),
])
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
if (! $sourceTenant instanceof Tenant) {
Notification::make()
->title('Source tenant not found')
->danger()
->send();
return;
}
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $sourceTenant, $user);
if (! $result['ok']) {
Notification::make()
->title('Cannot start capture')
->body('Reason: ' . str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
->danger()
->send();
return;
}
Notification::make()
->title('Capture enqueued')
->body('Baseline snapshot capture has been started.')
->success()
->send();
});
}
/**
* @return array<int, string>
*/
private function getWorkspaceTenantOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return [];
}
return Tenant::query()
->where('workspace_id', $workspaceId)
->orderBy('display_name')
->pluck('display_name', 'id')
->all();
}
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);
}
}

View File

@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities;
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 Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
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(),
])
->headerActions([
$this->assignTenantAction(),
])
->actions([
$this->removeAssignmentAction(),
])
->emptyStateHeading('No tenants assigned')
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
->emptyStateActions([
$this->assignTenantAction(),
]);
}
private function assignTenantAction(): Action
{
return Action::make('assign')
->label('Assign Tenant')
->icon('heroicon-o-plus')
->visible(fn (): bool => $this->hasManageCapability())
->form([
Select::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->getAvailableTenantOptions())
->required()
->searchable(),
])
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$tenantId = (int) $data['tenant_id'];
$existing = BaselineTenantAssignment::query()
->where('workspace_id', $profile->workspace_id)
->where('tenant_id', $tenantId)
->first();
if ($existing instanceof BaselineTenantAssignment) {
Notification::make()
->title('Tenant already assigned')
->body('This tenant already has a baseline assignment in this workspace.')
->warning()
->send();
return;
}
$assignment = BaselineTenantAssignment::create([
'workspace_id' => (int) $profile->workspace_id,
'tenant_id' => $tenantId,
'baseline_profile_id' => (int) $profile->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]);
$this->auditAssignment($profile, $assignment, $user, 'created');
Notification::make()
->title('Tenant assigned')
->success()
->send();
});
}
private function removeAssignmentAction(): Action
{
return Action::make('remove')
->label('Remove')
->icon('heroicon-o-trash')
->color('danger')
->visible(fn (): bool => $this->hasManageCapability())
->requiresConfirmation()
->modalHeading('Remove tenant assignment')
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
->action(function (BaselineTenantAssignment $record): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$this->auditAssignment($profile, $record, $user, 'removed');
$record->delete();
Notification::make()
->title('Assignment removed')
->success()
->send();
});
}
/**
* @return array<int, string>
*/
private function getAvailableTenantOptions(): array
{
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$assignedTenantIds = BaselineTenantAssignment::query()
->where('workspace_id', $profile->workspace_id)
->pluck('tenant_id')
->all();
$query = Tenant::query()
->where('workspace_id', $profile->workspace_id)
->orderBy('display_name');
if (! empty($assignedTenantIds)) {
$query->whereNotIn('id', $assignedTenantIds);
}
return $query->pluck('display_name', 'id')->all();
}
private function auditAssignment(
BaselineProfile $profile,
BaselineTenantAssignment $assignment,
User $user,
string $action,
): void {
$workspace = Workspace::query()->find($profile->workspace_id);
if (! $workspace instanceof Workspace) {
return;
}
$tenant = Tenant::query()->find($assignment->tenant_id);
$auditLogger = app(WorkspaceAuditLogger::class);
$auditLogger->log(
workspace: $workspace,
action: 'baseline.assignment.' . $action,
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
'tenant_id' => (int) $assignment->tenant_id,
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
],
actor: $user,
resourceType: 'baseline_profile',
resourceId: (string) $profile->getKey(),
);
}
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);
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class BaselineCompareNow extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'hasAssignment' => false,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'landingUrl' => null,
];
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->with('baselineProfile')
->first();
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
return [
'hasAssignment' => false,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'landingUrl' => null,
];
}
$profile = $assignment->baselineProfile;
$scopeKey = 'baseline_profile:' . $profile->getKey();
$findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->where('status', Finding::STATUS_NEW);
$findingsCount = (int) (clone $findingsQuery)->count();
$highCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_HIGH)
->count();
return [
'hasAssignment' => true,
'profileName' => (string) $profile->name,
'findingsCount' => $findingsCount,
'highCount' => $highCount,
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
];
}
}

View File

@ -0,0 +1,285 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class CaptureBaselineSnapshotJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public OperationRun $run,
) {
$this->operationRun = $run;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
BaselineSnapshotIdentity $identity,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
): void {
if (! $this->operationRun instanceof OperationRun) {
$this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.'));
return;
}
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
$sourceTenantId = (int) ($context['source_tenant_id'] ?? 0);
$profile = BaselineProfile::query()->find($profileId);
if (! $profile instanceof BaselineProfile) {
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
}
$sourceTenant = Tenant::query()->find($sourceTenantId);
if (! $sourceTenant instanceof Tenant) {
throw new RuntimeException("Source Tenant #{$sourceTenantId} not found.");
}
$initiator = $this->operationRun->user_id
? User::query()->find($this->operationRun->user_id)
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity);
$identityHash = $identity->computeIdentity($snapshotItems);
$snapshot = $this->findOrCreateSnapshot(
$profile,
$identityHash,
$snapshotItems,
);
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
if ($profile->status === BaselineProfile::STATUS_ACTIVE) {
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
}
$summaryCounts = [
'total' => count($snapshotItems),
'processed' => count($snapshotItems),
'succeeded' => count($snapshotItems),
'failed' => 0,
];
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: $summaryCounts,
);
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['result'] = [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => count($snapshotItems),
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems);
}
/**
* @return array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function collectSnapshotItems(
Tenant $sourceTenant,
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
): array {
$query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey());
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
$items = [];
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items, $identity): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$baselineHash = $identity->hashItemContent($metaJsonb);
$items[] = [
'subject_type' => 'policy',
'subject_external_id' => (string) $inventoryItem->external_id,
'policy_type' => (string) $inventoryItem->policy_type,
'baseline_hash' => $baselineHash,
'meta_jsonb' => [
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
],
];
}
});
return $items;
}
/**
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $snapshotItems
*/
private function findOrCreateSnapshot(
BaselineProfile $profile,
string $identityHash,
array $snapshotItems,
): BaselineSnapshot {
$existing = BaselineSnapshot::query()
->where('workspace_id', $profile->workspace_id)
->where('baseline_profile_id', $profile->getKey())
->where('snapshot_identity_hash', $identityHash)
->first();
if ($existing instanceof BaselineSnapshot) {
return $existing;
}
$snapshot = BaselineSnapshot::create([
'workspace_id' => (int) $profile->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'snapshot_identity_hash' => $identityHash,
'captured_at' => now(),
'summary_jsonb' => [
'total_items' => count($snapshotItems),
'policy_type_counts' => $this->countByPolicyType($snapshotItems),
],
]);
foreach (array_chunk($snapshotItems, 100) as $chunk) {
$rows = array_map(
fn (array $item): array => [
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => $item['subject_type'],
'subject_external_id' => $item['subject_external_id'],
'policy_type' => $item['policy_type'],
'baseline_hash' => $item['baseline_hash'],
'meta_jsonb' => json_encode($item['meta_jsonb']),
'created_at' => now(),
'updated_at' => now(),
],
$chunk,
);
BaselineSnapshotItem::insert($rows);
}
return $snapshot;
}
/**
* @param array<int, array{policy_type: string}> $items
* @return array<string, int>
*/
private function countByPolicyType(array $items): array
{
$counts = [];
foreach ($items as $item) {
$type = (string) $item['policy_type'];
$counts[$type] = ($counts[$type] ?? 0) + 1;
}
ksort($counts);
return $counts;
}
private function auditStarted(
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
): void {
$auditLogger->log(
tenant: $tenant,
action: 'baseline.capture.started',
context: [
'metadata' => [
'operation_run_id' => (int) $this->operationRun->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'baseline_profile',
resourceId: (string) $profile->getKey(),
);
}
private function auditCompleted(
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
BaselineSnapshot $snapshot,
?User $initiator,
array $snapshotItems,
): void {
$auditLogger->log(
tenant: $tenant,
action: 'baseline.capture.completed',
context: [
'metadata' => [
'operation_run_id' => (int) $this->operationRun->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
'items_captured' => count($snapshotItems),
'was_new_snapshot' => $snapshot->wasRecentlyCreated,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}

View File

@ -0,0 +1,394 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class CompareBaselineToTenantJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public OperationRun $run,
) {
$this->operationRun = $run;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new TrackOperationRun];
}
public function handle(
DriftHasher $driftHasher,
BaselineSnapshotIdentity $snapshotIdentity,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
): void {
if (! $this->operationRun instanceof OperationRun) {
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
return;
}
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
$snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0);
$profile = BaselineProfile::query()->find($profileId);
if (! $profile instanceof BaselineProfile) {
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
}
$tenant = Tenant::query()->find($this->operationRun->tenant_id);
if (! $tenant instanceof Tenant) {
throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
}
$initiator = $this->operationRun->user_id
? User::query()->find($this->operationRun->user_id)
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$scopeKey = 'baseline_profile:' . $profile->getKey();
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
$baselineItems = $this->loadBaselineItems($snapshotId);
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity);
$driftResults = $this->computeDrift($baselineItems, $currentItems);
$upsertedCount = $this->upsertFindings(
$driftHasher,
$tenant,
$profile,
$scopeKey,
$driftResults,
);
$severityBreakdown = $this->countBySeverity($driftResults);
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
'succeeded' => $upsertedCount,
'failed' => count($driftResults) - $upsertedCount,
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
];
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: $summaryCounts,
);
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['result'] = [
'findings_total' => count($driftResults),
'findings_upserted' => $upsertedCount,
'severity_breakdown' => $severityBreakdown,
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
}
/**
* Load baseline snapshot items keyed by "policy_type|subject_external_id".
*
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function loadBaselineItems(int $snapshotId): array
{
$items = [];
BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId)
->orderBy('id')
->chunk(500, function ($snapshotItems) use (&$items): void {
foreach ($snapshotItems as $item) {
$key = $item->policy_type . '|' . $item->subject_external_id;
$items[$key] = [
'subject_type' => (string) $item->subject_type,
'subject_external_id' => (string) $item->subject_external_id,
'policy_type' => (string) $item->policy_type,
'baseline_hash' => (string) $item->baseline_hash,
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
];
}
});
return $items;
}
/**
* Load current inventory items keyed by "policy_type|external_id".
*
* @return array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function loadCurrentInventory(
Tenant $tenant,
BaselineScope $scope,
BaselineSnapshotIdentity $snapshotIdentity,
): array {
$query = InventoryItem::query()
->where('tenant_id', $tenant->getKey());
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
$items = [];
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
$key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id;
$items[$key] = [
'subject_external_id' => (string) $inventoryItem->external_id,
'policy_type' => (string) $inventoryItem->policy_type,
'current_hash' => $currentHash,
'meta_jsonb' => [
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
],
];
}
});
return $items;
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
*/
private function computeDrift(array $baselineItems, array $currentItems): array
{
$drift = [];
foreach ($baselineItems as $key => $baselineItem) {
if (! array_key_exists($key, $currentItems)) {
$drift[] = [
'change_type' => 'missing_policy',
'severity' => Finding::SEVERITY_HIGH,
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'policy_type' => $baselineItem['policy_type'],
'baseline_hash' => $baselineItem['baseline_hash'],
'current_hash' => '',
'evidence' => [
'change_type' => 'missing_policy',
'policy_type' => $baselineItem['policy_type'],
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
],
];
continue;
}
$currentItem = $currentItems[$key];
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
$drift[] = [
'change_type' => 'different_version',
'severity' => Finding::SEVERITY_MEDIUM,
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'policy_type' => $baselineItem['policy_type'],
'baseline_hash' => $baselineItem['baseline_hash'],
'current_hash' => $currentItem['current_hash'],
'evidence' => [
'change_type' => 'different_version',
'policy_type' => $baselineItem['policy_type'],
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
'baseline_hash' => $baselineItem['baseline_hash'],
'current_hash' => $currentItem['current_hash'],
],
];
}
}
foreach ($currentItems as $key => $currentItem) {
if (! array_key_exists($key, $baselineItems)) {
$drift[] = [
'change_type' => 'unexpected_policy',
'severity' => Finding::SEVERITY_LOW,
'subject_type' => 'policy',
'subject_external_id' => $currentItem['subject_external_id'],
'policy_type' => $currentItem['policy_type'],
'baseline_hash' => '',
'current_hash' => $currentItem['current_hash'],
'evidence' => [
'change_type' => 'unexpected_policy',
'policy_type' => $currentItem['policy_type'],
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
],
];
}
}
return $drift;
}
/**
* Upsert drift findings using stable fingerprints.
*
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
*/
private function upsertFindings(
DriftHasher $driftHasher,
Tenant $tenant,
BaselineProfile $profile,
string $scopeKey,
array $driftResults,
): int {
$upsertedCount = 0;
$tenantId = (int) $tenant->getKey();
foreach ($driftResults as $driftItem) {
$fingerprint = $driftHasher->fingerprint(
tenantId: $tenantId,
scopeKey: $scopeKey,
subjectType: $driftItem['subject_type'],
subjectExternalId: $driftItem['subject_external_id'],
changeType: $driftItem['change_type'],
baselineHash: $driftItem['baseline_hash'],
currentHash: $driftItem['current_hash'],
);
Finding::query()->updateOrCreate(
[
'tenant_id' => $tenantId,
'fingerprint' => $fingerprint,
],
[
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => $scopeKey,
'subject_type' => $driftItem['subject_type'],
'subject_external_id' => $driftItem['subject_external_id'],
'severity' => $driftItem['severity'],
'status' => Finding::STATUS_NEW,
'evidence_jsonb' => $driftItem['evidence'],
'baseline_operation_run_id' => null,
'current_operation_run_id' => (int) $this->operationRun->getKey(),
],
);
$upsertedCount++;
}
return $upsertedCount;
}
/**
* @param array<int, array{severity: string}> $driftResults
* @return array<string, int>
*/
private function countBySeverity(array $driftResults): array
{
$counts = [];
foreach ($driftResults as $item) {
$severity = $item['severity'];
$counts[$severity] = ($counts[$severity] ?? 0) + 1;
}
return $counts;
}
private function auditStarted(
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
): void {
$auditLogger->log(
tenant: $tenant,
action: 'baseline.compare.started',
context: [
'metadata' => [
'operation_run_id' => (int) $this->operationRun->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'baseline_profile',
resourceId: (string) $profile->getKey(),
);
}
private function auditCompleted(
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
array $summaryCounts,
): void {
$auditLogger->log(
tenant: $tenant,
action: 'baseline.compare.completed',
context: [
'metadata' => [
'operation_run_id' => (int) $this->operationRun->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name,
'findings_total' => $summaryCounts['total'] ?? 0,
'high' => $summaryCounts['high'] ?? 0,
'medium' => $summaryCounts['medium'] ?? 0,
'low' => $summaryCounts['low'] ?? 0,
],
],
actorId: $initiator?->id,
actorEmail: $initiator?->email,
actorName: $initiator?->name,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BaselineProfile extends Model
{
use HasFactory;
public const string STATUS_DRAFT = 'draft';
public const string STATUS_ACTIVE = 'active';
public const string STATUS_ARCHIVED = 'archived';
protected $guarded = [];
protected $casts = [
'scope_jsonb' => 'array',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function createdByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function activeSnapshot(): BelongsTo
{
return $this->belongsTo(BaselineSnapshot::class, 'active_snapshot_id');
}
public function snapshots(): HasMany
{
return $this->hasMany(BaselineSnapshot::class);
}
public function tenantAssignments(): HasMany
{
return $this->hasMany(BaselineTenantAssignment::class);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BaselineSnapshot extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'summary_jsonb' => 'array',
'captured_at' => 'datetime',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function baselineProfile(): BelongsTo
{
return $this->belongsTo(BaselineProfile::class);
}
public function items(): HasMany
{
return $this->hasMany(BaselineSnapshotItem::class);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BaselineSnapshotItem extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'meta_jsonb' => 'array',
];
public function snapshot(): BelongsTo
{
return $this->belongsTo(BaselineSnapshot::class, 'baseline_snapshot_id');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BaselineTenantAssignment extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
protected $guarded = [];
protected $casts = [
'override_scope_jsonb' => 'array',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function baselineProfile(): BelongsTo
{
return $this->belongsTo(BaselineProfile::class);
}
public function assignedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by_user_id');
}
}

View File

@ -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')

View File

@ -36,6 +36,8 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_SETTINGS_MANAGE,
Capabilities::ALERTS_VIEW,
Capabilities::ALERTS_MANAGE,
Capabilities::WORKSPACE_BASELINES_VIEW,
Capabilities::WORKSPACE_BASELINES_MANAGE,
],
WorkspaceRole::Manager->value => [
@ -54,6 +56,8 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_SETTINGS_MANAGE,
Capabilities::ALERTS_VIEW,
Capabilities::ALERTS_MANAGE,
Capabilities::WORKSPACE_BASELINES_VIEW,
Capabilities::WORKSPACE_BASELINES_MANAGE,
],
WorkspaceRole::Operator->value => [
@ -66,12 +70,14 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
Capabilities::WORKSPACE_SETTINGS_VIEW,
Capabilities::ALERTS_VIEW,
Capabilities::WORKSPACE_BASELINES_VIEW,
],
WorkspaceRole::Readonly->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_SETTINGS_VIEW,
Capabilities::ALERTS_VIEW,
Capabilities::WORKSPACE_BASELINES_VIEW,
],
];

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
final class BaselineCaptureService
{
public function __construct(
private readonly OperationRunService $runs,
) {}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
*/
public function startCapture(
BaselineProfile $profile,
Tenant $sourceTenant,
User $initiator,
): array {
$precondition = $this->validatePreconditions($profile, $sourceTenant);
if ($precondition !== null) {
return ['ok' => false, 'reason_code' => $precondition];
}
$effectiveScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
$context = [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toJsonb(),
];
$run = $this->runs->ensureRunWithIdentity(
tenant: $sourceTenant,
type: 'baseline_capture',
identityInputs: [
'baseline_profile_id' => (int) $profile->getKey(),
],
context: $context,
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
CaptureBaselineSnapshotJob::dispatch($run);
}
return ['ok' => true, 'run' => $run];
}
private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string
{
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE;
}
if ($sourceTenant->workspace_id === null) {
return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
}
if ((int) $sourceTenant->workspace_id !== (int) $profile->workspace_id) {
return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
}
return null;
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
final class BaselineCompareService
{
public function __construct(
private readonly OperationRunService $runs,
) {}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
*/
public function startCompare(
Tenant $tenant,
User $initiator,
): array {
$assignment = BaselineTenantAssignment::query()
->where('workspace_id', $tenant->workspace_id)
->where('tenant_id', $tenant->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
}
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
if (! $profile instanceof BaselineProfile) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
}
$precondition = $this->validatePreconditions($profile);
if ($precondition !== null) {
return ['ok' => false, 'reason_code' => $precondition];
}
$snapshotId = (int) $profile->active_snapshot_id;
$profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
$overrideScope = $assignment->override_scope_jsonb !== null
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
$context = [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toJsonb(),
];
$run = $this->runs->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: [
'baseline_profile_id' => (int) $profile->getKey(),
],
context: $context,
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
CompareBaselineToTenantJob::dispatch($run);
}
return ['ok' => true, 'run' => $run];
}
private function validatePreconditions(BaselineProfile $profile): ?string
{
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
}
if ($profile->active_snapshot_id === null) {
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
}
return null;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Services\Drift\DriftHasher;
/**
* Computes the snapshot_identity_hash for baseline snapshot content dedupe.
*
* The identity hash is a sha256 over normalized snapshot items, enabling
* detection of "nothing changed" when capturing the same inventory state.
*/
final class BaselineSnapshotIdentity
{
public function __construct(
private readonly DriftHasher $hasher,
) {}
/**
* Compute identity hash over a set of snapshot items.
*
* Each item is represented as an associative array with:
* - subject_type, subject_external_id, policy_type, baseline_hash
*
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string}> $items
*/
public function computeIdentity(array $items): string
{
if ($items === []) {
return hash('sha256', '[]');
}
$normalized = array_map(
fn (array $item): string => implode('|', [
trim((string) ($item['subject_type'] ?? '')),
trim((string) ($item['subject_external_id'] ?? '')),
trim((string) ($item['policy_type'] ?? '')),
trim((string) ($item['baseline_hash'] ?? '')),
]),
$items,
);
sort($normalized, SORT_STRING);
return hash('sha256', implode("\n", $normalized));
}
/**
* Compute a stable content hash for a single inventory item's metadata.
*
* Strips volatile OData keys and normalizes for stable comparison.
*/
public function hashItemContent(mixed $metaJsonb): string
{
return $this->hasher->hashNormalized($metaJsonb);
}
}

View File

@ -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';
}

View File

@ -96,6 +96,11 @@ class Capabilities
public const PROVIDER_RUN = 'provider.run';
// Workspace baselines (Golden Master governance)
public const WORKSPACE_BASELINES_VIEW = 'workspace_baselines.view';
public const WORKSPACE_BASELINES_MANAGE = 'workspace_baselines.manage';
// Audit
public const AUDIT_VIEW = 'audit.view';

View File

@ -39,6 +39,7 @@ final class BadgeCatalog
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
];
/**

View File

@ -31,4 +31,5 @@ enum BadgeDomain: string
case VerificationReportOverall = 'verification_report_overall';
case AlertDeliveryStatus = 'alert_delivery_status';
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
case BaselineProfileStatus = 'baseline_profile_status';
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\BaselineProfile;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineProfileStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
BaselineProfile::STATUS_DRAFT => new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'),
BaselineProfile::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
BaselineProfile::STATUS_ARCHIVED => new BadgeSpec('Archived', 'warning', 'heroicon-m-archive-box'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
/**
* Stable reason codes for 422 precondition failures.
*
* These codes are returned in the response body when a baseline operation
* cannot start due to unmet preconditions. No OperationRun is created.
*/
final class BaselineReasonCodes
{
public const string CAPTURE_MISSING_SOURCE_TENANT = 'baseline.capture.missing_source_tenant';
public const string CAPTURE_PROFILE_NOT_ACTIVE = 'baseline.capture.profile_not_active';
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
}

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
/**
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
* An empty policy_types array means "all types" (no filter).
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
*/
public function __construct(
public readonly array $policyTypes = [],
) {}
/**
* Create from the scope_jsonb column value.
*
* @param array<string, mixed>|null $scopeJsonb
*/
public static function fromJsonb(?array $scopeJsonb): self
{
if ($scopeJsonb === null) {
return new self;
}
$policyTypes = $scopeJsonb['policy_types'] ?? [];
return new self(
policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
);
}
/**
* Normalize the effective scope by intersecting profile scope with an optional override.
*
* Override can only narrow the profile scope (subset enforcement).
* If the profile scope is empty (all types), the override becomes the effective scope.
* If the override is empty or null, the profile scope is used as-is.
*/
public static function effective(self $profileScope, ?self $overrideScope): self
{
if ($overrideScope === null || $overrideScope->isEmpty()) {
return $profileScope;
}
if ($profileScope->isEmpty()) {
return $overrideScope;
}
$intersected = array_values(array_intersect($profileScope->policyTypes, $overrideScope->policyTypes));
return new self(policyTypes: $intersected);
}
/**
* An empty scope means "all types".
*/
public function isEmpty(): bool
{
return $this->policyTypes === [];
}
/**
* Check if a policy type is included in this scope.
*/
public function includes(string $policyType): bool
{
if ($this->isEmpty()) {
return true;
}
return in_array($policyType, $this->policyTypes, true);
}
/**
* Validate that override is a subset of the profile scope.
*/
public static function isValidOverride(self $profileScope, self $overrideScope): bool
{
if ($overrideScope->isEmpty()) {
return true;
}
if ($profileScope->isEmpty()) {
return true;
}
foreach ($overrideScope->policyTypes as $type) {
if (! in_array($type, $profileScope->policyTypes, true)) {
return false;
}
}
return true;
}
/**
* @return array<string, mixed>
*/
public function toJsonb(): array
{
return [
'policy_types' => $this->policyTypes,
];
}
}

View File

@ -45,6 +45,8 @@ public static function labels(): array
'policy_version.force_delete' => 'Delete policy versions',
'alerts.evaluate' => 'Alerts evaluation',
'alerts.deliver' => 'Alerts delivery',
'baseline_capture' => 'Baseline capture',
'baseline_compare' => 'Baseline compare',
];
}
@ -72,6 +74,8 @@ public static function expectedDurationSeconds(string $operationType): ?int
'assignments.fetch', 'assignments.restore' => 60,
'ops.reconcile_adapter_runs' => 120,
'alerts.evaluate', 'alerts.deliver' => 120,
'baseline_capture' => 120,
'baseline_compare' => 120,
default => null,
};
}

View File

@ -24,6 +24,9 @@ public static function all(): array
'deleted',
'items',
'tenants',
'high',
'medium',
'low',
];
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace Database\Factories;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineProfile>
*/
class BaselineProfileFactory extends Factory
{
protected $model = BaselineProfile::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'name' => fake()->unique()->words(3, true),
'description' => fake()->optional()->sentence(),
'version_label' => fake()->optional()->numerify('v#.#'),
'status' => BaselineProfile::STATUS_DRAFT,
'scope_jsonb' => ['policy_types' => []],
'active_snapshot_id' => null,
'created_by_user_id' => null,
];
}
public function active(): static
{
return $this->state(fn (): array => [
'status' => BaselineProfile::STATUS_ACTIVE,
]);
}
public function archived(): static
{
return $this->state(fn (): array => [
'status' => BaselineProfile::STATUS_ARCHIVED,
]);
}
public function withScope(array $policyTypes): static
{
return $this->state(fn (): array => [
'scope_jsonb' => ['policy_types' => $policyTypes],
]);
}
public function createdBy(User $user): static
{
return $this->state(fn (): array => [
'created_by_user_id' => $user->getKey(),
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineSnapshot>
*/
class BaselineSnapshotFactory extends Factory
{
protected $model = BaselineSnapshot::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'baseline_profile_id' => BaselineProfile::factory(),
'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
'captured_at' => now(),
'summary_jsonb' => ['total_items' => 0],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineSnapshotItem>
*/
class BaselineSnapshotItemFactory extends Factory
{
protected $model = BaselineSnapshotItem::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'baseline_snapshot_id' => BaselineSnapshot::factory(),
'subject_type' => 'policy',
'subject_external_id' => fake()->uuid(),
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', fake()->uuid()),
'meta_jsonb' => ['display_name' => fake()->words(3, true)],
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BaselineTenantAssignment>
*/
class BaselineTenantAssignmentFactory extends Factory
{
protected $model = BaselineTenantAssignment::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'tenant_id' => Tenant::factory(),
'baseline_profile_id' => BaselineProfile::factory(),
'override_scope_jsonb' => null,
'assigned_by_user_id' => null,
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->string('version_label')->nullable();
$table->string('status')->default('draft');
$table->jsonb('scope_jsonb');
$table->unsignedBigInteger('active_snapshot_id')->nullable();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['workspace_id', 'status']);
$table->unique(['workspace_id', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('baseline_profiles');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_snapshots', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('baseline_profile_id')->constrained('baseline_profiles')->cascadeOnDelete();
$table->string('snapshot_identity_hash', 64);
$table->timestampTz('captured_at');
$table->jsonb('summary_jsonb')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'baseline_profile_id', 'snapshot_identity_hash'], 'baseline_snapshots_dedupe_unique');
$table->index(['workspace_id', 'baseline_profile_id', 'captured_at'], 'baseline_snapshots_lookup_idx');
});
Schema::table('baseline_profiles', function (Blueprint $table): void {
$table->foreign('active_snapshot_id')
->references('id')
->on('baseline_snapshots')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('baseline_profiles', function (Blueprint $table): void {
$table->dropForeign(['active_snapshot_id']);
});
Schema::dropIfExists('baseline_snapshots');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_snapshot_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('baseline_snapshot_id')->constrained('baseline_snapshots')->cascadeOnDelete();
$table->string('subject_type');
$table->string('subject_external_id');
$table->string('policy_type');
$table->string('baseline_hash', 64);
$table->jsonb('meta_jsonb')->nullable();
$table->timestamps();
$table->unique(
['baseline_snapshot_id', 'subject_type', 'subject_external_id'],
'baseline_snapshot_items_subject_unique'
);
$table->index(['baseline_snapshot_id', 'policy_type'], 'baseline_snapshot_items_policy_type_idx');
});
}
public function down(): void
{
Schema::dropIfExists('baseline_snapshot_items');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('baseline_tenant_assignments', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('baseline_profile_id')->constrained('baseline_profiles')->cascadeOnDelete();
$table->jsonb('override_scope_jsonb')->nullable();
$table->foreignId('assigned_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['workspace_id', 'tenant_id']);
$table->index(['workspace_id', 'baseline_profile_id']);
});
}
public function down(): void
{
Schema::dropIfExists('baseline_tenant_assignments');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('findings', function (Blueprint $table): void {
$table->string('source')->nullable()->after('finding_type');
$table->index(['tenant_id', 'source']);
});
}
public function down(): void
{
Schema::table('findings', function (Blueprint $table): void {
$table->dropIndex(['tenant_id', 'source']);
$table->dropColumn('source');
});
}
};

View File

@ -0,0 +1,93 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Compare the current tenant state against the assigned baseline profile to detect drift.
</div>
@if (filled($profileName))
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-gray-950 dark:text-white">Baseline Profile:</span>
<span class="text-gray-700 dark:text-gray-200">{{ $profileName }}</span>
@if ($snapshotId)
<x-filament::badge color="success" size="sm">
Snapshot #{{ $snapshotId }}
</x-filament::badge>
@endif
</div>
@endif
@if ($state === 'no_tenant')
<x-filament::badge color="gray">No tenant selected</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'no_assignment')
<x-filament::badge color="gray">No baseline assigned</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'no_snapshot')
<x-filament::badge color="warning">No snapshot available</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'comparing')
<div class="flex items-center gap-2">
<x-filament::badge color="info">Comparing…</x-filament::badge>
<x-filament::loading-indicator class="h-4 w-4" />
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View operation run
</x-filament::link>
@endif
@elseif ($state === 'idle')
<x-filament::badge color="gray">Ready to compare</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'ready')
@if ($findingsCount !== null && $findingsCount > 0)
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if (($severityCounts['high'] ?? 0) > 0)
<x-filament::badge color="danger" size="sm">
{{ $severityCounts['high'] }} high
</x-filament::badge>
@endif
@if (($severityCounts['medium'] ?? 0) > 0)
<x-filament::badge color="warning" size="sm">
{{ $severityCounts['medium'] }} medium
</x-filament::badge>
@endif
@if (($severityCounts['low'] ?? 0) > 0)
<x-filament::badge color="gray" size="sm">
{{ $severityCounts['low'] }} low
</x-filament::badge>
@endif
</div>
@if ($this->getFindingsUrl())
<x-filament::link :href="$this->getFindingsUrl()" size="sm">
View findings
</x-filament::link>
@endif
@else
<x-filament::badge color="success" size="sm">
No drift detected
</x-filament::badge>
@if (filled($message))
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@endif
@endif
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View last run
</x-filament::link>
@endif
@endif
</div>
</x-filament::section>
</x-filament::page>

View File

@ -0,0 +1,41 @@
<div class="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="flex flex-col gap-3">
<div class="text-base font-semibold text-gray-950 dark:text-white">
Soll vs Ist
</div>
@if (! $hasAssignment)
<div class="text-sm text-gray-500 dark:text-gray-400">
No baseline profile assigned yet.
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
Baseline: <span class="font-medium">{{ $profileName }}</span>
</div>
@if ($findingsCount > 0)
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} open {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if ($highCount > 0)
<x-filament::badge color="danger" size="sm">
{{ $highCount }} high
</x-filament::badge>
@endif
</div>
@else
<x-filament::badge color="success" size="sm">
No open drift
</x-filament::badge>
@endif
@if ($landingUrl)
<x-filament::link :href="$landingUrl" size="sm">
Go to Soll vs Ist
</x-filament::link>
@endif
@endif
</div>
</div>

View File

@ -0,0 +1,50 @@
# Specification Quality Checklist: Golden Master / Baseline Governance v1 (R1.1R1.4)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-19
**Last Validated**: 2026-02-19 (post-analysis remediation)
**Feature**: [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
## Post-Analysis Remediation (2026-02-19)
- [x] D1: Duplicate FR-003 removed (kept complete version with override clause)
- [x] I1: Status lifecycle resolved — `draft ↔ active → archived` (deactivate = return to draft); spec.md + data-model.md aligned
- [x] I3: "Delete" removed from UI Action Matrix — v1 is archive-only, no hard-delete
- [x] C1: BadgeDomain task (T018a) added for BaselineProfileStatus badge compliance (BADGE-001)
- [x] C2: Factory tasks (T018b) added for all 4 new models
- [x] U1: `findings.source` default resolved — nullable, NULL default, legacy findings unaffected
- [x] U2: Empty-scope edge case (EC-005) covered in T031 test description
- [x] U3: Concurrent operation dedup (EC-004) covered in T032 + T040 test descriptions
- [x] U4: T033 amended with explicit manage-capability gating via WorkspaceUiEnforcement
- [x] U5: `scope_jsonb` schema defined in data-model.md (`{ "policy_types": [...] }`)
- [x] E1: SC-001/SC-002 performance spot-check added to T060
## Notes
- All 12 analysis findings have been remediated
- Task count updated: 60 → 62 (T018a, T018b added in Phase 2)

View File

@ -0,0 +1,156 @@
openapi: 3.0.3
info:
title: Baseline Governance v1 (Golden Master)
version: 1.0.0
description: |
Conceptual HTTP contract for Baseline Governance actions.
Note: The implementation is Filament + Livewire; these endpoints describe the server-side behavior
(authorization, precondition failures, operation run creation) in a REST-like form for clarity.
servers:
- url: /admin
paths:
/workspaces/{workspaceId}/baselines:
get:
summary: List baseline profiles
parameters:
- $ref: '#/components/parameters/workspaceId'
responses:
'200':
description: OK
/workspaces/{workspaceId}/baselines/{baselineProfileId}:
get:
summary: View baseline profile
parameters:
- $ref: '#/components/parameters/workspaceId'
- $ref: '#/components/parameters/baselineProfileId'
responses:
'200':
description: OK
'404':
description: Not found (workspace not entitled)
'403':
description: Forbidden (missing capability)
/workspaces/{workspaceId}/baselines/{baselineProfileId}/capture:
post:
summary: Capture immutable baseline snapshot from a tenant
parameters:
- $ref: '#/components/parameters/workspaceId'
- $ref: '#/components/parameters/baselineProfileId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [source_tenant_id]
properties:
source_tenant_id:
type: integer
responses:
'202':
description: Enqueued (OperationRun created/reused)
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunStartResponse'
'422':
description: Precondition failure (no OperationRun created)
content:
application/json:
schema:
$ref: '#/components/schemas/PreconditionFailure'
examples:
missingSourceTenant:
value:
reason_code: baseline.capture.missing_source_tenant
'404':
description: Not found (workspace not entitled)
'403':
description: Forbidden (missing capability)
/tenants/{tenantId}/baseline-compare:
post:
summary: Compare tenant state to assigned baseline and generate drift findings
parameters:
- $ref: '#/components/parameters/tenantId'
responses:
'202':
description: Enqueued (OperationRun created/reused)
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunStartResponse'
'422':
description: Precondition failure (no OperationRun created)
content:
application/json:
schema:
$ref: '#/components/schemas/PreconditionFailure'
examples:
noAssignment:
value:
reason_code: baseline.compare.no_assignment
profileNotActive:
value:
reason_code: baseline.compare.profile_not_active
noActiveSnapshot:
value:
reason_code: baseline.compare.no_active_snapshot
'404':
description: Not found (tenant/workspace not entitled)
'403':
description: Forbidden (missing capability)
/tenants/{tenantId}/baseline-compare/latest:
get:
summary: Fetch latest baseline compare summary for tenant
parameters:
- $ref: '#/components/parameters/tenantId'
responses:
'200':
description: OK
components:
parameters:
workspaceId:
name: workspaceId
in: path
required: true
schema:
type: integer
tenantId:
name: tenantId
in: path
required: true
schema:
type: integer
baselineProfileId:
name: baselineProfileId
in: path
required: true
schema:
type: integer
schemas:
OperationRunStartResponse:
type: object
required: [operation_run_id]
properties:
operation_run_id:
type: integer
reused:
type: boolean
description: True if an already-queued/running run was returned
PreconditionFailure:
type: object
required: [reason_code]
properties:
reason_code:
type: string
description: Stable code for UI + support triage

View File

@ -0,0 +1,142 @@
# Phase 1 — Data Model
This is the proposed data model for **101 — Golden Master / Baseline Governance v1**.
## Ownership Model
- **Workspace-owned**
- Baseline profiles
- Baseline snapshots + snapshot items (data-minimized, reusable across tenants)
- **Tenant-owned**
- Tenant assignments (joins a tenant to the workspace baseline standard)
- Operation runs for capture/compare
- Findings produced by compares
This follows constitution SCOPE-001 conventions: tenant-owned tables include `workspace_id` + `tenant_id` NOT NULL; workspace-owned tables include `workspace_id` and do not include `tenant_id`.
## Entities
## 1) baseline_profiles (workspace-owned)
**Purpose**: Defines the baseline (“what good looks like”) and its scope.
**Fields**
- `id` (pk)
- `workspace_id` (fk workspaces, NOT NULL)
- `name` (string, NOT NULL)
- `description` (text, nullable)
- `version_label` (string, nullable)
- `status` (string enum: `draft|active|archived`, NOT NULL)
- `scope_jsonb` (jsonb, NOT NULL)
- v1 schema: `{ "policy_types": ["string", ...] }` — array of policy type keys from `InventoryPolicyTypeMeta`
- An empty array means "all types" (no filtering); each string must be a known policy type key
- Future versions may add additional filter dimensions (e.g., `platforms`, `tags`)
- `active_snapshot_id` (fk baseline_snapshots, nullable)
- `created_by_user_id` (fk users, nullable)
- timestamps
**Indexes/constraints**
- index: `(workspace_id, status)`
- uniqueness: `(workspace_id, name)` (optional but recommended if UI expects names unique per workspace)
**Validation notes**
- status transitions: `draft ↔ active → archived` (archived is terminal in v1; deactivate returns active → draft)
## 2) baseline_snapshots (workspace-owned)
**Purpose**: Immutable baseline snapshot captured from a tenant.
**Fields**
- `id` (pk)
- `workspace_id` (fk workspaces, NOT NULL)
- `baseline_profile_id` (fk baseline_profiles, NOT NULL)
- `snapshot_identity_hash` (string(64), NOT NULL)
- sha256 of normalized captured content
- `captured_at` (timestamp tz, NOT NULL)
- `summary_jsonb` (jsonb, nullable)
- counts/metadata (e.g., total items)
- timestamps
**Indexes/constraints**
- unique: `(workspace_id, baseline_profile_id, snapshot_identity_hash)` (dedupe)
- index: `(workspace_id, baseline_profile_id, captured_at desc)`
## 3) baseline_snapshot_items (workspace-owned)
**Purpose**: Immutable items within a snapshot.
**Fields**
- `id` (pk)
- `baseline_snapshot_id` (fk baseline_snapshots, NOT NULL)
- `subject_type` (string, NOT NULL) — e.g. `policy`
- `subject_external_id` (string, NOT NULL) — stable key for the policy within the tenant inventory
- `policy_type` (string, NOT NULL) — for filtering and summary
- `baseline_hash` (string(64), NOT NULL) — stable content hash for the baseline version
- `meta_jsonb` (jsonb, nullable) — minimized display metadata (no secrets)
- timestamps
**Indexes/constraints**
- unique: `(baseline_snapshot_id, subject_type, subject_external_id)`
- index: `(baseline_snapshot_id, policy_type)`
## 4) baseline_tenant_assignments (tenant-owned)
**Purpose**: Assigns exactly one baseline profile per tenant (v1), with optional scope override that can only narrow.
**Fields**
- `id` (pk)
- `workspace_id` (fk workspaces, NOT NULL)
- `tenant_id` (fk tenants, NOT NULL)
- `baseline_profile_id` (fk baseline_profiles, NOT NULL)
- `override_scope_jsonb` (jsonb, nullable)
- `assigned_by_user_id` (fk users, nullable)
- timestamps
**Indexes/constraints**
- unique: `(workspace_id, tenant_id)`
- index: `(workspace_id, baseline_profile_id)`
**Validation notes**
- Override must be subset of profile scope (enforced server-side; store the final effective scope hash in the compare run context for traceability).
## 5) findings additions (tenant-owned)
**Purpose**: Persist baseline compare drift findings using existing findings system.
**Required v1 additions**
- add `findings.source` (string, **nullable**, default `NULL`)
- baseline compare uses `source='baseline.compare'`
- existing findings receive `NULL`; legacy drift-generate findings are queried with `whereNull('source')` or unconditionally
- index: `(tenant_id, source)`
**Baseline compare finding conventions**
- `finding_type = 'drift'`
- `scope_key = 'baseline_profile:<id>'`
- `fingerprint = sha256(tenant_id|scope_key|subject_type|subject_external_id|change_type|baseline_hash|current_hash)` (using `DriftHasher`)
- `evidence_jsonb` includes:
- `source` (until DB column exists)
- `baseline.profile_id`, `baseline.snapshot_id`
- `baseline.hash`, `current.hash`
- `change_type` (missing_policy|different_version|unexpected_policy)
- any minimized diff pointers required for UI
## 6) operation_runs conventions
**Operation types**
- `baseline_capture`
- `baseline_compare`
**Run context** (json)
- capture:
- `baseline_profile_id`
- `source_tenant_id`
- `effective_scope` / `selection_hash`
- compare:
- `baseline_profile_id`
- `baseline_snapshot_id` (frozen at enqueue time)
- `effective_scope` / `selection_hash`
**Summary counts**
- compare should set a compact breakdown for dashboards:
- totals and severity breakdowns

View File

@ -0,0 +1,166 @@
#+#+#+#+
# Implementation Plan: Golden Master / Baseline Governance v1 (R1.1R1.4)
**Branch**: `101-golden-master-baseline-governance-v1` | **Date**: 2026-02-19 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
## Summary
Implement a workspace-owned **Golden Master baseline** that can be captured from a tenant into an immutable snapshot, assigned to tenants, and compared (“Soll vs Ist”) to generate drift findings + a compact operational summary.
This feature reuses existing patterns:
- tenant-scoped `OperationRun` for capture/compare observability + idempotency
- `findings` for drift tracking with sha256 fingerprints
## Technical Context
**Language/Version**: PHP 8.4.x
**Framework**: Laravel 12
**Admin UI**: Filament v5 (requires Livewire v4.0+)
**Storage**: PostgreSQL (Sail locally); SQLite is used in some tests
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
**Target Platform**: Docker via Laravel Sail (macOS dev)
**Project Type**: Laravel monolith (server-rendered Filament + Livewire)
**Performance Goals**:
- Compare completes within ~2 minutes for typical tenants (≤ 500 in-scope policies)
**Constraints**:
- Baseline/monitoring pages must be DB-only at render time (no outbound HTTP)
- Operation start surfaces enqueue-only (no remote work inline)
**Scale/Scope**:
- Multi-tenant, workspace isolation is an authorization boundary
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: compare operates on “last observed” inventory/policy versions; baseline snapshot is an explicit immutable capture.
- Read/write separation: capture/compare are operational jobs; any mutations are confirmed, audited, and tested.
- Graph contract path: no Graph calls on UI surfaces; any Graph usage (if introduced) must go through `GraphClientInterface` and `config/graph_contracts.php`.
- Deterministic capabilities: new baseline capabilities are added to the canonical registry and role maps.
- RBAC-UX semantics: non-member is 404; member but missing capability is 403.
- Run observability: capture/compare are always `OperationRun`-tracked; start surfaces only create/reuse runs and enqueue work.
- Data minimization: workspace-owned baseline snapshots are minimized and must not store tenant secrets.
- Badge semantics (BADGE-001): finding severity/status uses existing `BadgeCatalog` mapping.
- Filament Action Surface Contract: resources/pages created or modified must define Header/Row/Bulk/Empty-State + inspect affordance.
✅ Phase 0 and Phase 1 design choices satisfy the constitution. No violations are required.
## Project Structure
### Documentation (this feature)
```text
specs/101-golden-master-baseline-governance-v1/
├── plan.md
├── spec.md
├── research.md
├── data-model.md
├── quickstart.md
└── contracts/
└── baseline-governance.openapi.yaml
```
### Source Code (repository root)
```text
app/
├── Filament/
├── Jobs/
├── Models/
├── Policies/
├── Services/
└── Support/
database/
└── migrations/
resources/
└── views/
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Implement baseline governance as standard Laravel models, migrations, services, jobs, and Filament resources/pages under the existing `app/` conventions.
## Phase 0 — Research (completed)
Outputs:
- [research.md](research.md)
Key decisions captured:
- Workspace-owned, data-minimized baseline snapshots (reusable across tenants)
- `OperationRun` types `baseline_capture` and `baseline_compare` using `ensureRunWithIdentity()` for idempotent starts
- Precondition failures return 422 with stable `reason_code` and do not create runs
- Findings reuse `findings` with sha256 fingerprints; Phase 2 adds `findings.source` to satisfy `source = baseline.compare`
## Phase 1 — Design & Contracts (completed)
Outputs:
- [data-model.md](data-model.md)
- [contracts/baseline-governance.openapi.yaml](contracts/baseline-governance.openapi.yaml)
- [quickstart.md](quickstart.md)
### UI Surfaces (Filament v5)
- Workspace-context: Governance → Baselines
- Resource for baseline profile CRUD
- Capture action (manage capability)
- Tenant assignment management (manage capability)
- Tenant-context: “Soll vs Ist” landing page
- Compare-now action (view capability)
- Links to latest compare run + findings
### Authorization & capabilities
- Add new workspace capabilities:
- `workspace_baselines.view`
- `workspace_baselines.manage`
- Enforce membership as 404; capability denial as 403.
### Operation types
- `baseline_capture` (tenant = source tenant)
- `baseline_compare` (tenant = target/current tenant)
### Agent context update (completed)
The agent context update script was run during planning. It should be re-run after this plan update if the automation depends on parsed plan values.
## Phase 2 — Execution Plan (to be translated into tasks.md)
1) **Migrations + models**
- Add baseline tables (`baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`)
- Add `findings.source` column + index to meet FR-015
2) **Services**
- Baseline scope resolution (profile scope ∩ assignment override)
- Snapshot identity calculation and dedupe strategy
- Compare engine to produce drift items and severity mapping (FR-009)
3) **Jobs (queued)**
- `CaptureBaselineSnapshotJob` (creates snapshot, items, updates active snapshot when applicable)
- `CompareBaselineToTenantJob` (creates/updates findings + writes run summary_counts)
4) **Filament UI**
- Baseline profiles resource (list/view/edit) with action surfaces per spec matrix
- Tenant assignment UI surface (v1: single baseline per tenant)
- “Soll vs Ist” tenant landing page and dashboard card action
5) **RBAC + policies**
- Capability registry + role maps + UI enforcement
- Regression tests for 404 vs 403 semantics
6) **Tests (Pest)**
- CRUD authorization + action surface expectations
- Capture snapshot dedupe
- Compare finding fingerprint idempotency
- Precondition failures return 422 and create no `OperationRun`
7) **Formatting**
- Run `vendor/bin/sail bin pint --dirty`
## Complexity Tracking
None.

View File

@ -0,0 +1,60 @@
# Phase 1 — Quickstart (Developer)
This quickstart is for exercising Baseline Governance v1 locally.
## Prereqs
- Docker running
- Laravel Sail available
## Setup
1. Start containers: `vendor/bin/sail up -d`
2. Install deps (if needed): `vendor/bin/sail composer install`
3. Migrate: `vendor/bin/sail artisan migrate`
4. Build frontend assets (if UI changes arent visible): `vendor/bin/sail npm run dev`
## Happy path walkthrough
### 1) Create a baseline profile
- Navigate to Admin → Governance → Baselines
- Create a profile with:
- name
- status = draft
- scope filter (policy types/domains)
### 2) Capture from a source tenant
- From the Baseline Profile view page, trigger “Capture from tenant”
- Select a source tenant
- Confirm the action
- You should see a queued notification with “View run” that links to Monitoring → Operations
Expected:
- An `OperationRun` of type `baseline_capture` is created (or reused if one is already queued/running)
- On success, an immutable `baseline_snapshot` is created and the profiles `active_snapshot_id` is updated (when profile is active)
### 3) Assign baseline to a tenant
- Navigate to the tenant context (Admin → choose tenant)
- Assign the baseline profile to the tenant (v1: exactly one baseline per tenant)
- Optionally define an override filter that narrows scope
### 4) Compare now (Soll vs Ist)
- Navigate to the “Soll vs Ist” landing page for the tenant
- Click “Compare now”
Expected:
- An `OperationRun` of type `baseline_compare` is created/reused
- Findings are created/updated with stable fingerprints
- The compare run summary is persisted (totals + severity breakdown)
## Precondition failure checks
These should return **HTTP 422** with `reason_code`, and must **not** create an `OperationRun`:
- compare with no assignment: `baseline.compare.no_assignment`
- compare when profile not active: `baseline.compare.profile_not_active`
- compare when no active snapshot: `baseline.compare.no_active_snapshot`
- capture with missing source tenant: `baseline.capture.missing_source_tenant`
## Test focus (when implementation lands)
- BaselineProfile CRUD + RBAC (404 vs 403)
- Capture idempotency (dedupe snapshot identity)
- Compare idempotency (dedupe finding fingerprint)
- Action surfaces comply with the Filament Action Surface Contract

View File

@ -0,0 +1,101 @@
# Phase 0 — Research
This document records the key technical decisions for **101 — Golden Master / Baseline Governance v1 (R1.1R1.4)**, grounded in existing TenantPilot patterns.
## Existing System Constraints (confirmed in repo)
### Operation runs are tenant-scoped and deduped at DB level
- `OperationRunService::ensureRunWithIdentity()` requires a `Tenant` with a non-null `workspace_id`, and always creates runs with `workspace_id` + `tenant_id`.
- Active-run idempotency is enforced via the `operation_runs_active_unique` partial unique index (queued/running).
**Implication**: Baseline capture/compare must always be executed as **tenant-owned `OperationRun` records**, even though baseline profiles are workspace-owned.
### Findings fingerprinting expects sha256 (64 chars)
- `findings.fingerprint` is `string(64)` and unique by `(tenant_id, fingerprint)`.
- `DriftHasher` already implements a stable sha256 fingerprint scheme.
**Implication**: Any baseline-compare “finding key” should be hashed before storage, and must be stable across repeated compares.
## Decisions
### D-001 — Baseline snapshot storage is workspace-owned (and data-minimized)
**Decision**: Store `baseline_snapshots` and `baseline_snapshot_items` as **workspace-owned** tables (`workspace_id` NOT NULL, **no `tenant_id`**) so a golden master snapshot can be used across tenants without requiring access to the source tenant.
**Rationale**:
- The product intent is a workspace-level standard (“Golden Master”) reusable across multiple tenants.
- Treat the snapshot as a **standard artifact**, not tenant evidence, and enforce strict data-minimization so we do not leak tenant-specific content.
**Guardrails**:
- Snapshot items store only policy identity + stable content hashes and minimal display metadata.
- Any tenant identifiers (e.g., “captured from tenant”) live in the **capture `OperationRun.context`** and audit logs, not on workspace-owned snapshot rows.
**Alternatives considered**:
- Tenant-owned baseline snapshot (include `tenant_id`): rejected because it would require cross-tenant reads of tenant-owned records to compare other tenants, which would either violate tenant isolation or force “must be member of the source tenant” semantics.
### D-002 — OperationRun types and identity inputs
**Decision**:
- Introduce `OperationRun.type` values:
- `baseline_capture`
- `baseline_compare`
- Use `OperationRunService::ensureRunWithIdentity()` for idempotent start surfaces.
**Identity inputs**:
- `baseline_capture`: identity inputs include `baseline_profile_id`.
- `baseline_compare`: identity inputs include `baseline_profile_id`.
**Rationale**:
- Guarantees one active run per tenant+baseline profile (matches partial unique index behavior).
- Keeps identity stable even if the active snapshot is switched mid-flight; the run context should freeze `baseline_snapshot_id` at enqueue time for determinism.
**Alternatives considered**:
- Include `baseline_snapshot_id` in identity: rejected for v1 because we primarily want “single active compare per tenant/profile”, not “single active compare per snapshot”.
### D-003 — Precondition failures return 422 and do not create OperationRuns
**Decision**: Enforce FR-014 exactly:
- The start surface validates preconditions **before** calling `OperationRunService`.
- If unmet, return **HTTP 422** with a stable `reason_code` and **do not** create an `OperationRun`.
**Rationale**:
- Aligns with spec clarifications and avoids polluting Monitoring → Operations with non-startable attempts.
### D-004 — Findings storage uses existing `findings` table; add a source discriminator
**Decision**:
- Store baseline-compare drift as `Finding::FINDING_TYPE_DRIFT`.
- Persist `source = baseline.compare` per FR-015.
**Implementation note**:
- The current `findings` schema does not have a `source` column.
- In Phase 2 implementation we should add `findings.source` (string, **nullable**, default `NULL`) with an index `(tenant_id, source)` and use `source='baseline.compare'`.
- Existing findings receive `NULL` — legacy drift-generate findings are queried with `whereNull('source')` or unconditionally.
- A future backfill migration may set `source='drift.generate'` for historical findings if needed for reporting.
**Alternatives considered**:
- Store `source` only in `evidence_jsonb`: workable, but makes filtering and long-term reporting harder and is less explicit.
- Non-null default `'drift.generate'`: rejected because retroactively tagging all existing findings requires careful validation and is a separate concern.
### D-005 — Baseline compare scope_key strategy
**Decision**: Use `findings.scope_key = 'baseline_profile:' . baseline_profile_id` for baseline-compare findings.
**Rationale**:
- Keeps a stable grouping key for tenant UI (“Soll vs Ist” for the assigned baseline).
- Avoids over-coupling to inventory selection hashes in v1.
### D-006 — Authorization model (404 vs 403)
**Decision**:
- Membership is enforced as deny-as-not-found (404) via existing membership checks.
- Capability denials are 403 after membership is established.
**Capabilities**:
- Add workspace capabilities:
- `workspace_baselines.view`
- `workspace_baselines.manage`
**Rationale**:
- Matches the feature specs two-capability requirement.
- Keeps baseline governance controlled at workspace plane, while still enforcing tenant membership for tenant-context pages/actions.
**Alternatives considered**:
- Add a tenant-plane capability for compare start: rejected for v1 to keep to the two-capability spec and avoid introducing a second permission axis for the same action.
## Open Questions (none blocking Phase 1)
- None.

View File

@ -0,0 +1,167 @@
# Feature Specification: Golden Master / Baseline Governance v1 (R1.1R1.4)
**Feature Branch**: `101-golden-master-baseline-governance-v1`
**Created**: 2026-02-19
**Status**: Draft
**Input**: Introduce a workspace-owned “Golden Master” baseline that can be captured from a tenant, compared against the current tenant state, and surfaced in the UI as “Soll vs Ist” with drift findings and an operational summary.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: Admin Governance “Baselines” area; tenant-facing dashboard card; drift comparison landing (“Soll vs Ist”)
- **Data Ownership**: Baselines and snapshots are workspace-owned; tenant assignments are tenant-owned (workspace_id + tenant_id); drift findings are tenant-owned (workspace_id + tenant_id)
- **RBAC**: workspace membership required for any visibility; capability gating for view vs manage (see Requirements)
## Clarifications
### Session 2026-02-19
- Q: Which v1 drift severity mapping should we use? → A: Fixed mapping: missing_policy=high, different_version=medium, unexpected_policy=low.
- Q: Which snapshot should compare runs use in v1? → A: Always use BaselineProfile.active_snapshot; if missing, block compare with a clear reason.
- Q: How should compare/capture handle precondition failures? → A: UI disables where possible AND server blocks start with 422 + stable reason_code; no OperationRun is created for failed preconditions.
- Q: Where should drift findings be stored in v1? → A: Use existing findings storage with source=baseline.compare; fingerprint/idempotency lives there.
- Q: How should tenant override filters combine with the profile filter? → A: Override narrows scope; effective filter = profile ∩ override.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Create and manage a baseline profile (Priority: P1)
As a workspace governance owner/manager, I can define what “good” looks like by creating a Baseline Profile, controlling its status (draft/active/archived), and scoping which policy domains are included.
**Why this priority**: Without a baseline profile, there is nothing to capture or compare against.
**Independent Test**: A user with baseline manage rights can create/edit/archive a baseline profile and see it listed; a read-only user can list/view but cannot mutate.
**Acceptance Scenarios**:
1. **Given** I am a workspace member with baseline manage capability, **When** I create a baseline profile with a name, optional description, optional version label, and a policy-domain filter, **Then** the profile is persisted and visible in the Baselines list.
2. **Given** I am a workspace member with baseline view-only capability, **When** I open a baseline profile, **Then** I can view its details but cannot edit/archive/delete it.
3. **Given** I am not a member of the workspace, **When** I attempt to access baseline pages, **Then** I receive a not-found response (deny-as-not-found).
---
### User Story 2 - Capture an immutable baseline snapshot from a tenant (Priority: P2)
As a baseline manager, I can capture a snapshot of the current tenant configuration that the baseline profile covers, producing an immutable “baseline snapshot” that can later be compared.
**Why this priority**: The baseline must be based on a real, point-in-time state to be meaningful and auditable.
**Independent Test**: Capturing twice with unchanged tenant state reuses the same snapshot identity and does not create duplicates.
**Acceptance Scenarios**:
1. **Given** a baseline profile exists and I have baseline manage capability, **When** I trigger “Capture from tenant” and choose a source tenant, **Then** a new capture operation is created and eventually produces an immutable snapshot.
2. **Given** a capture was already completed for the same baseline profile and the tenants relevant policies are unchanged, **When** I capture again, **Then** the system reuses the existing snapshot (idempotent/deduped).
3. **Given** the baseline profile is active, **When** a capture completes successfully, **Then** the profiles “active snapshot” points to the captured snapshot.
---
### User Story 3 - Compare baseline vs current tenant to detect drift (Priority: P3)
As an operator/manager, I can run “Compare now” for a tenant, and the system produces drift findings and a summary that can be used for assurance and triage.
**Why this priority**: Drift detection is the core governance signal; it makes the baseline actionable.
**Independent Test**: A compare run produces findings for missing policies, different versions, and unexpected policies, and stores a compact summary.
**Acceptance Scenarios**:
1. **Given** a tenant is assigned to an active baseline profile with an active snapshot, **When** I run “Compare now”, **Then** a compare operation runs and produces drift findings and a drift summary.
2. **Given** the same drift item is detected in repeated compares, **When** compares are run multiple times, **Then** the same finding is updated (idempotent fingerprint) rather than duplicated.
3. **Given** I am a workspace member without baseline view capability, **When** I try to start a compare, **Then** the request is forbidden.
### Edge Cases
- Baseline profile is draft or archived: compare is blocked; users are told what must be changed (e.g., “activate baseline”).
- Tenant has no baseline assignment: compare button is disabled and the UI explains why.
- Baseline profile has no active snapshot yet: compare is blocked with a clear reason.
- Concurrent operation starts: the system prevents multiple “active” captures/compares for the same scope.
- Baseline filter yields no relevant policies: capture creates an empty snapshot and compare returns “no items checked”, without errors.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001 (Baseline Profiles)**: The system MUST allow workspace baseline managers to create, edit, activate, deactivate (return to draft), and archive baseline profiles. Status transitions: draft ↔ active → archived (archived is terminal in v1).
- **FR-002 (Scope Control)**: Each baseline profile MUST define which policy domains/types are included in the baseline.
- **FR-003 (Tenant Assignment)**: The system MUST support assigning exactly one baseline profile per tenant per workspace (v1), with an optional per-tenant override of the profiles scope; in v1 the override may only narrow scope (effective filter = profile ∩ override).
- **FR-004 (Capture as Operation)**: Capturing a baseline snapshot MUST be tracked as an observable operation with a clear lifecycle (started/completed/failed).
- **FR-005 (Immutable Snapshots)**: Baseline snapshots and their snapshot items MUST be immutable once created.
- **FR-006 (Capture Idempotency)**: Captures MUST be deduplicated so that repeated captures with the same effective content reuse the existing snapshot identity.
- **FR-007 (Compare as Operation)**: Comparing a tenant against its baseline MUST be tracked as an observable operation with a clear lifecycle.
- **FR-008 (Drift Findings)**: Compare MUST produce drift findings using at least these drift types: missing baseline policy, different version, and unexpected policy within the baseline scope.
- **FR-009 (Severity Rules)**: Drift findings MUST be assigned severities using centrally defined rules so that severity is consistent and testable; v1 fixed mapping is: missing baseline policy = high, different version = medium, unexpected policy = low.
- **FR-010 (Finding Idempotency)**: Drift findings MUST be deduplicated with a stable fingerprint so repeated compares update existing open findings instead of creating duplicates.
- **FR-011 (Summary Output)**: Each compare operation MUST persist a summary containing totals and severity breakdowns suitable for dashboards.
- **FR-012 (UI “Soll vs Ist”)**: The UI MUST allow selecting a baseline profile and viewing the latest compare runs and drift findings for a tenant.
- **FR-013 (Compare Snapshot Selection)**: Compare runs MUST always use the baseline profiles active snapshot; if no active snapshot exists, compare MUST be blocked with a clear reason.
- **FR-014 (Precondition Failure Contract)**: When capture/compare cannot start due to unmet preconditions, the UI MUST disable the action where possible, and the server MUST reject the request with HTTP 422 containing a stable `reason_code`; in this case, the system MUST NOT create an OperationRun.
- **FR-015 (Findings Storage)**: Drift findings produced by compare MUST be persisted using the existing findings system with `source = baseline.compare`.
### Precondition `reason_code` (v1)
- `baseline.compare.no_assignment`
- `baseline.compare.profile_not_active`
- `baseline.compare.no_active_snapshot`
- `baseline.capture.missing_source_tenant`
### Constitution Alignment: Safety, Isolation, Observability
- **Ops/Observability**: Capture and compare MUST be observable and auditable operations, and surfaced in the existing operations monitoring experience.
- **DB-only UI**: Baseline pages and drift pages MUST NOT require outbound network calls during page render or user clicks that only start/view operations; external calls (if any) must happen in background work.
- **Tenant Isolation / RBAC semantics**:
- non-member of workspace or tenant scope → deny-as-not-found (404)
- member but missing capability → forbidden (403)
- **Least privilege**: Two capabilities MUST exist and be enforced:
- baseline view: can view baselines and start compare operations
- baseline manage: can manage baselines, assignments, and capture snapshots
- **Auditability**: Baseline-related operations MUST emit audit log entries for started/completed/failed events.
- **Safe logging**: Failures MUST not include secrets or sensitive tenant data; failure reasons must be stable and suitable for support triage.
## UI Action Matrix *(mandatory when Filament is changed)*
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline Profiles | Admin → Governance → Baselines | Create baseline profile | View/Edit page available | View, Edit | Archive (grouped) | Create baseline profile | Capture from tenant; Activate/Deactivate; Assign to tenants | Save, Cancel | Yes | Destructive actions require confirmation; mutations are manage-gated; hard-delete is out of scope for v1 (archive only) |
| Drift Landing (“Soll vs Ist”) | Tenant view | Run compare now | Links to last compare operation and findings list | — | — | — | — | — | Yes | Starting compare is view-gated; results visible only to entitled users |
| Tenant Dashboard Card | Tenant dashboard | Run compare now | Click to drift landing and/or last compare operation | — | — | — | — | — | Yes | Button disabled with explanation when no assignment or no active snapshot |
## Key Entities *(include if feature involves data)*
- **Baseline Profile**: A workspace-owned definition of what should be in-scope for governance, with a lifecycle status (draft/active/archived).
- **Tenant Assignment**: A workspace-managed mapping that declares which baseline applies to a tenant, optionally overriding scope.
- **Baseline Snapshot**: An immutable point-in-time record of the baselines in-scope policy references captured from a tenant.
- **Snapshot Item**: A single baseline entry representing one in-scope policy reference in the snapshot.
- **Drift Finding**: A record representing an observed deviation between baseline and tenant state, deduplicated by a stable fingerprint.
- **Drift Finding Source**: Drift findings produced by this feature use `source = baseline.compare`.
- **Operation Summary**: A compact, persisted summary of a capture/compare run suitable for dashboard display.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A baseline manager can create a baseline profile and perform an initial capture in under 5 minutes.
- **SC-002**: For a typical tenant (≤ 500 in-scope policies), a compare run completes and surfaces a summary within 2 minutes.
- **SC-003**: Re-running compare within 24 hours for unchanged drift does not create duplicate findings (0 duplicate drift fingerprints).
- **SC-004**: Unauthorized users (non-members) receive no baseline visibility (deny-as-not-found) and members without capability cannot mutate (forbidden).
## Non-Goals (v1)
- Evidence packs / stored reports for audit exports
- Advanced findings workflow (exceptions, auto-closing, recurrence handling)
- Cross-tenant portfolio comparisons
- Baseline inheritance across organizations (e.g., MSP → customer)
- Assignment/scope-tag baselines beyond the policy domains/types included in the profile scope
## Assumptions
- Tenants already have an inventory of policy versions that can be referenced for capture and compare.
- An operations monitoring experience exists where capture/compare runs can be viewed.
- A drift findings system exists that can store and display findings and severities.
## Dependencies
- Inventory + policy version history is available and trustworthy.
- Operation run tracking and monitoring is available.
- RBAC + UI enforcement semantics are already established (404 for non-member, 403 for missing capability).
- Alerts are optional for v1; the feature remains valuable without alert integrations.

View File

@ -0,0 +1,209 @@
---
description: "Task breakdown for implementing Feature 101"
---
# Tasks: Golden Master / Baseline Governance v1
**Input**: Design documents from `specs/101-golden-master-baseline-governance-v1/`
**Key constraints (from spec/plan)**
- Filament v5 + Livewire v4.0+ only.
- UI surfaces are DB-only at render time (no outbound HTTP).
- Capture/compare start surfaces are enqueue-only (no remote work inline).
- Precondition failures return **HTTP 422** with stable `reason_code` and **must not** create an `OperationRun`.
- RBAC semantics: non-member → **404** (deny-as-not-found); member but missing capability → **403**.
- Findings use sha256 64-char fingerprints (reuse `App\Services\Drift\DriftHasher`).
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure the repo is ready for implementation + verification.
- [X] T001 Verify feature docs are in place in specs/101-golden-master-baseline-governance-v1/
- [X] T002 Verify local dev prerequisites via quickstart in specs/101-golden-master-baseline-governance-v1/quickstart.md
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core primitives used across all user stories (schema, capabilities, operation UX).
**Checkpoint**: After this phase, US1/US2/US3 work can proceed.
- [X] T003 Add baseline capabilities to app/Support/Auth/Capabilities.php (`workspace_baselines.view`, `workspace_baselines.manage`)
- [X] T004 Add baseline capabilities to app/Services/Auth/WorkspaceRoleCapabilityMap.php (Owner/Manager = manage; Operator/Readonly = view)
- [X] T005 [P] Add operation labels for baseline_capture + baseline_compare in app/Support/OperationCatalog.php
- [X] T006 [P] Extend summary keys to include severity breakdown keys in app/Support/OpsUx/OperationSummaryKeys.php (add `high`, `medium`, `low`)
- [X] T007 Create baseline schema migrations in database/migrations/*_create_baseline_profiles_table.php (workspace-owned)
- [X] T008 Create baseline schema migrations in database/migrations/*_create_baseline_snapshots_table.php (workspace-owned)
- [X] T009 Create baseline schema migrations in database/migrations/*_create_baseline_snapshot_items_table.php (workspace-owned)
- [X] T010 Create baseline schema migrations in database/migrations/*_create_baseline_tenant_assignments_table.php (tenant-owned)
- [X] T011 Create findings source migration in database/migrations/*_add_source_to_findings_table.php
- [X] T012 [P] Add BaselineProfile model in app/Models/BaselineProfile.php (casts for scope_jsonb; status constants/enum)
- [X] T013 [P] Add BaselineSnapshot model in app/Models/BaselineSnapshot.php (casts for summary_jsonb)
- [X] T014 [P] Add BaselineSnapshotItem model in app/Models/BaselineSnapshotItem.php (casts for meta_jsonb)
- [X] T015 [P] Add BaselineTenantAssignment model in app/Models/BaselineTenantAssignment.php (casts for override_scope_jsonb)
- [X] T016 [P] Update Finding model for new column in app/Models/Finding.php (fillable/casts for `source` as needed by existing conventions)
- [X] T017 [P] Add baseline scope value object / helpers in app/Support/Baselines/BaselineScope.php (normalize + intersect profile/override)
- [X] T018 [P] Add baseline reason codes in app/Support/Baselines/BaselineReasonCodes.php (constants for 422 responses)
- [X] T018a [P] Add BadgeDomain::BaselineProfileStatus case to app/Support/Badges/BadgeDomain.php + create domain mapper class in app/Support/Badges/Domains/BaselineProfileStatusBadges.php + register in BadgeCatalog (BADGE-001 compliance for draft/active/archived)
- [X] T018b [P] Add model factories: database/factories/BaselineProfileFactory.php, BaselineSnapshotFactory.php, BaselineSnapshotItemFactory.php, BaselineTenantAssignmentFactory.php (required for Pest tests in Phase 35)
---
## Phase 3: User Story 1 — Baseline Profile CRUD (Priority: P1) 🎯 MVP
**Goal**: Workspace managers define baseline profiles (scope + lifecycle) with correct RBAC and action surfaces.
**Independent Test**:
- As a member with `workspace_baselines.manage`, create/edit/archive a profile.
- As a member with `workspace_baselines.view`, list/view but cannot mutate.
- As a non-member, baseline pages/actions deny-as-not-found (404).
### Tests (required in this repo)
- [X] T019 [P] [US1] Add feature test scaffolding in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
- [X] T020 [P] [US1] Add 404 vs 403 semantics tests in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
- [X] T021 [P] [US1] Add Action Surface contract coverage for the new resource in tests/Feature/Guards/ActionSurfaceValidatorTest.php (resource discovered or explicitly whitelisted per existing test conventions)
### Implementation
- [X] T022 [US1] Create Baseline Profile Filament resource in app/Filament/Resources/BaselineProfileResource.php (navigation group = Governance)
- [X] T023 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php
- [X] T024 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php
- [X] T025 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
- [X] T026 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php
- [X] T027 [US1] Implement form/table schema in app/Filament/Resources/BaselineProfileResource.php (scope editor backed by App\Support\Inventory\InventoryPolicyTypeMeta)
- [X] T028 [US1] Implement RBAC (404 vs 403) and UI enforcement in app/Filament/Resources/BaselineProfileResource.php using App\Services\Auth\WorkspaceCapabilityResolver + App\Support\Rbac\WorkspaceUiEnforcement
- [X] T029 [US1] Implement audit logging for baseline profile mutations in app/Filament/Resources/BaselineProfileResource.php using App\Services\Audit\WorkspaceAuditLogger
- [X] T030 [US1] Ensure the resource is safe for global search (either keep View/Edit pages enabled OR disable global search explicitly) in app/Filament/Resources/BaselineProfileResource.php
**Checkpoint**: Baseline profiles CRUD works and is independently testable.
---
## Phase 4: User Story 2 — Capture Immutable Baseline Snapshot (Priority: P2)
**Goal**: Managers can enqueue capture from a tenant to create (or reuse) an immutable, workspace-owned snapshot.
**Independent Test**:
- Trigger capture twice for unchanged effective content → the same `snapshot_identity_hash` is reused (no duplicates).
- If profile is active, capture success updates `active_snapshot_id`.
### Tests (required in this repo)
- [X] T031 [P] [US2] Add capture enqueue + precondition tests in tests/Feature/Baselines/BaselineCaptureTest.php (include: empty-scope capture produces empty snapshot without errors [EC-005])
- [X] T032 [P] [US2] Add snapshot dedupe test in tests/Feature/Baselines/BaselineCaptureTest.php (include: concurrent capture request for same scope reuses active run [EC-004])
### Implementation
- [X] T033 [P] [US2] Add capture action UI to app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php (Action::make()->action()->requiresConfirmation(); select source tenant; gated by `workspace_baselines.manage` via WorkspaceUiEnforcement — disabled with tooltip for view-only members, server-side 403 on execution)
- [X] T034 [US2] Add capture start service in app/Services/Baselines/BaselineCaptureService.php (precondition validation before OperationRun creation; enqueue via OperationRunService)
- [X] T035 [P] [US2] Add snapshot identity helper in app/Services/Baselines/BaselineSnapshotIdentity.php (sha256 over normalized items; reuse DriftHasher::hashNormalized where appropriate)
- [X] T036 [P] [US2] Add capture job in app/Jobs/CaptureBaselineSnapshotJob.php (DB-only reads; creates snapshot + items; updates profile active snapshot when applicable)
- [X] T037 [US2] Add audit logging for capture started/completed/failed in app/Jobs/CaptureBaselineSnapshotJob.php using App\Services\Intune\AuditLogger
- [X] T038 [US2] Persist run context + summary_counts for capture in app/Jobs/CaptureBaselineSnapshotJob.php (use only allowed summary keys)
**Checkpoint**: Capture creates immutable snapshots and dedupes repeated capture.
---
## Phase 5: User Story 3 — Assign + Compare (“Soll vs Ist”) (Priority: P3)
**Goal**: Operators can assign a baseline to a tenant (v1: exactly one), then enqueue compare-now to generate drift findings + summary.
**Independent Test**:
- With assignment + active snapshot: compare enqueues an operation and produces findings + summary.
- Re-running compare updates existing findings (same fingerprint) rather than duplicating.
### Tests (required in this repo)
- [X] T039 [P] [US3] Add assignment CRUD tests (RBAC + uniqueness) in tests/Feature/Baselines/BaselineAssignmentTest.php
- [X] T040 [P] [US3] Add compare precondition 422 tests (no OperationRun created) in tests/Feature/Baselines/BaselineComparePreconditionsTest.php (include: concurrent compare reuses active run [EC-004]; draft/archived profile blocks compare [EC-001])
- [X] T041 [P] [US3] Add compare idempotent finding fingerprint tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php
- [X] T042 [P] [US3] Add compare summary_counts severity breakdown tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php
### Implementation — Assignment (v1)
- [X] T043 [P] [US3] Add BaselineTenantAssignments relation manager in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
- [X] T044 [US3] Enforce assignment RBAC (404 vs 403) and audits in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php (manage-gated mutations; view-gated visibility)
- [X] T045 [US3] Implement effective scope validation (override narrows only) in app/Services/Baselines/BaselineScope.php
### Implementation — Tenant landing + dashboard card
- [X] T046 [P] [US3] Add tenant landing page in app/Filament/Pages/BaselineCompareLanding.php (navigation label “Soll vs Ist”; DB-only)
- [X] T047 [P] [US3] Add landing view in resources/views/filament/pages/baseline-compare-landing.blade.php (shows assignment state, last run link, findings link)
- [X] T048 [P] [US3] Add tenant dashboard card widget in app/Filament/Widgets/Dashboard/BaselineCompareNow.php
- [X] T049 [US3] Wire the widget into app/Filament/Pages/TenantDashboard.php (add widget class to getWidgets())
### Implementation — Compare engine + job
- [X] T050 [US3] Add compare start service in app/Services/Baselines/BaselineCompareService.php (preconditions; enqueue OperationRun; freeze snapshot_id in run context)
- [X] T051 [P] [US3] Add compare job in app/Jobs/CompareBaselineToTenantJob.php (DB-only; compute drift items; upsert findings with `source='baseline.compare'`)
- [X] T052 [US3] Implement fingerprinting + idempotent upsert in app/Jobs/CompareBaselineToTenantJob.php (use App\Services\Drift\DriftHasher::fingerprint)
- [X] T053 [US3] Implement severity mapping (missing=high, different=medium, unexpected=low) in app/Jobs/CompareBaselineToTenantJob.php
- [X] T054 [US3] Persist run summary_counts with totals + severity breakdown in app/Jobs/CompareBaselineToTenantJob.php (requires T006)
- [X] T055 [US3] Add audit logging for compare started/completed/failed in app/Jobs/CompareBaselineToTenantJob.php using App\Services\Intune\AuditLogger
**Checkpoint**: Tenant “Soll vs Ist” UI works end-to-end and compare generates deduped findings.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T056 [P] Add operation duration hinting for new operation types in app/Support/OperationCatalog.php (expectedDurationSeconds for baseline_capture/baseline_compare)
- [X] T057 Ensure all destructive actions have confirmation in app/Filament/Resources/BaselineProfileResource.php and app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
- [X] T058 Run formatting on touched files via `vendor/bin/sail bin pint --dirty`
- [X] T059 Run focused test suite via `vendor/bin/sail artisan test --compact tests/Feature/Baselines`
- [X] T060 Run the quickstart walkthrough in specs/101-golden-master-baseline-governance-v1/quickstart.md and adjust any mismatches; spot-check SC-001 (create+capture < 5 min) and SC-002 (compare < 2 min for 500 policies)
---
## Dependencies & Execution Order
### Phase Dependencies
- Phase 1 (Setup) → Phase 2 (Foundational) → US phases
- Phase 2 blocks US1/US2/US3.
### User Story Dependencies
- **US1 (P1)** is the MVP and enables profile CRUD.
- **US2 (P2)** depends on US1 (needs profiles) and Phase 2 schema/services.
- **US3 (P3)** depends on US1 (needs profiles) and Phase 2 schema; compare also depends on US2 having produced an active snapshot.
---
## Parallel Execution Examples
### US1 parallelizable work
- T023, T024, T025, T026 (resource pages) can be implemented in parallel.
- T019 and T020 (authorization tests) can be implemented in parallel with the resource skeleton.
### US2 parallelizable work
- T035 (snapshot identity helper) and T036 (capture job) can be implemented in parallel once the schema exists.
- T031T032 tests can be written before the job/service implementation.
### US3 parallelizable work
- T046T048 (landing page + view + widget) can be done in parallel with T050T054 (compare service/job).
- T039T042 tests can be written before implementation.
---
## Implementation Strategy
### MVP First (US1 only)
1. Complete Phase 1 + Phase 2
2. Deliver Phase 3 (US1)
3. Run: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
### Incremental delivery
- Add US2 (capture) → validate dedupe + active snapshot updates
- Add US3 (assign + compare) → validate 422 contract + idempotent findings + summary output

View File

@ -0,0 +1,126 @@
<?php
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
// --- T039: Assignment CRUD tests (RBAC + uniqueness) ---
it('creates a tenant assignment with workspace context', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$assignment = BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]);
expect($assignment)->toBeInstanceOf(BaselineTenantAssignment::class);
expect($assignment->workspace_id)->toBe((int) $tenant->workspace_id);
expect($assignment->tenant_id)->toBe((int) $tenant->getKey());
expect($assignment->baseline_profile_id)->toBe((int) $profile->getKey());
expect($assignment->assigned_by_user_id)->toBe((int) $user->getKey());
});
it('prevents duplicate assignments for the same workspace+tenant', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile1 = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$profile2 = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile1->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]);
// Attempting to assign the same tenant in the same workspace should fail
// due to the unique constraint on (workspace_id, tenant_id)
expect(fn () => BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile2->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]))->toThrow(\Illuminate\Database\QueryException::class);
});
it('allows the same tenant to be assigned in different workspaces', function () {
[$user1, $tenant1] = createUserWithTenant(role: 'owner');
[$user2, $tenant2] = createUserWithTenant(role: 'owner');
$profile1 = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant1->workspace_id,
]);
$profile2 = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant2->workspace_id,
]);
$a1 = BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant1->workspace_id,
'tenant_id' => (int) $tenant1->getKey(),
'baseline_profile_id' => (int) $profile1->getKey(),
]);
$a2 = BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant2->workspace_id,
'tenant_id' => (int) $tenant2->getKey(),
'baseline_profile_id' => (int) $profile2->getKey(),
]);
expect($a1->exists)->toBeTrue();
expect($a2->exists)->toBeTrue();
});
it('deletes an assignment without deleting related models', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$assignment = BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'assigned_by_user_id' => (int) $user->getKey(),
]);
$assignmentId = $assignment->getKey();
$assignment->delete();
expect(BaselineTenantAssignment::query()->find($assignmentId))->toBeNull();
expect(BaselineProfile::query()->find($profile->getKey()))->not->toBeNull();
expect(Tenant::query()->find($tenant->getKey()))->not->toBeNull();
});
it('loads the baseline profile relationship from assignment', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'name' => 'Test Profile',
]);
$assignment = BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$loaded = $assignment->baselineProfile;
expect($loaded)->not->toBeNull();
expect($loaded->name)->toBe('Test Profile');
});

View File

@ -0,0 +1,349 @@
<?php
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use Illuminate\Support\Facades\Queue;
// --- T031: Capture enqueue + precondition tests ---
it('enqueues capture for an active profile and creates an operation run', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
/** @var BaselineCaptureService $service */
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
expect($result)->toHaveKey('run');
/** @var OperationRun $run */
$run = $result['run'];
expect($run->type)->toBe('baseline_capture');
expect($run->status)->toBe('queued');
expect($run->tenant_id)->toBe((int) $tenant->getKey());
$context = is_array($run->context) ? $run->context : [];
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
expect($context['source_tenant_id'])->toBe((int) $tenant->getKey());
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
});
it('rejects capture for a draft profile with reason code', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->create([
'workspace_id' => $tenant->workspace_id,
'status' => BaselineProfile::STATUS_DRAFT,
]);
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture for an archived profile with reason code', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->archived()->create([
'workspace_id' => $tenant->workspace_id,
]);
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture for a tenant from a different workspace', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$otherUser, $otherTenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$service = app(BaselineCaptureService::class);
$result = $service->startCapture($profile, $otherTenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe('baseline.capture.missing_source_tenant');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
});
// --- T032: Concurrent capture reuses active run [EC-004] ---
it('reuses an existing active run for the same profile/tenant instead of creating a new one', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$service = app(BaselineCaptureService::class);
$result1 = $service->startCapture($profile, $tenant, $user);
$result2 = $service->startCapture($profile, $tenant, $user);
expect($result1['ok'])->toBeTrue();
expect($result2['ok'])->toBeTrue();
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1);
});
// --- Snapshot dedupe + capture job execution ---
it('creates a snapshot with items when the capture job executes', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
InventoryItem::factory()->count(3)->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'],
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CaptureBaselineSnapshotJob($run);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(3);
expect((int) ($counts['succeeded'] ?? 0))->toBe(3);
$snapshot = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
->first();
expect($snapshot)->not->toBeNull();
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3);
$profile->refresh();
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
});
it('dedupes snapshots when content is unchanged', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
InventoryItem::factory()->count(2)->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
]);
$opService = app(OperationRunService::class);
$idService = app(BaselineSnapshotIdentity::class);
$auditLogger = app(AuditLogger::class);
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job1 = new CaptureBaselineSnapshotJob($run1);
$job1->handle($idService, $auditLogger, $opService);
$snapshotCountAfterFirst = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
->count();
expect($snapshotCountAfterFirst)->toBe(1);
$run2 = OperationRun::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'baseline_capture',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
]);
$job2 = new CaptureBaselineSnapshotJob($run2);
$job2->handle($idService, $auditLogger, $opService);
$snapshotCountAfterSecond = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
->count();
expect($snapshotCountAfterSecond)->toBe(1);
});
// --- EC-005: Empty scope produces empty snapshot without errors ---
it('captures an empty snapshot when no inventory items match the scope', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']],
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['nonExistentPolicyType']],
],
initiator: $user,
);
$job = new CaptureBaselineSnapshotJob($run);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(0);
expect((int) ($counts['failed'] ?? 0))->toBe(0);
$snapshot = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
->first();
expect($snapshot)->not->toBeNull();
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
});
it('captures all inventory items when scope has empty policy_types (all types)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => []],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'compliancePolicy',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => []],
],
initiator: $user,
);
$job = new CaptureBaselineSnapshotJob($run);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(2);
$snapshot = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
->first();
expect($snapshot)->not->toBeNull();
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(2);
});

View File

@ -0,0 +1,414 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
// --- T041: Compare idempotent finding fingerprint tests ---
it('creates drift findings when baseline and tenant inventory differ', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// Baseline has policyA and policyB
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-a-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'content-a'),
'meta_jsonb' => ['display_name' => 'Policy A'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-b-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'content-b'),
'meta_jsonb' => ['display_name' => 'Policy B'],
]);
// Tenant has policyA (different content) and policyC (unexpected)
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-a-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-c-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['new_policy' => true],
'display_name' => 'Policy C unexpected',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$scopeKey = 'baseline_profile:' . $profile->getKey();
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->get();
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
expect($findings->count())->toBe(3);
$severities = $findings->pluck('severity')->sort()->values()->all();
expect($severities)->toContain(Finding::SEVERITY_HIGH);
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
expect($severities)->toContain(Finding::SEVERITY_LOW);
});
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'),
'meta_jsonb' => ['display_name' => 'Policy X'],
]);
// Tenant does NOT have policy-x → missing_policy finding
$opService = app(OperationRunService::class);
// First run
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job1 = new CompareBaselineToTenantJob($run1);
$job1->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:' . $profile->getKey();
$countAfterFirst = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count();
expect($countAfterFirst)->toBe(1);
// Second run - new OperationRun so we can dispatch again
// Mark first run as completed so ensureRunWithIdentity creates a new one
$run1->update(['status' => 'completed', 'completed_at' => now()]);
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job2 = new CompareBaselineToTenantJob($run2);
$job2->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$countAfterSecond = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count();
// Same fingerprint → same finding updated, not duplicated
expect($countAfterSecond)->toBe(1);
});
it('creates zero findings when baseline matches tenant inventory exactly', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// Baseline item
$metaContent = ['policy_key' => 'value123'];
$driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'matching-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $contentHash,
'meta_jsonb' => ['display_name' => 'Matching Policy'],
]);
// Tenant inventory with same content → same hash
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'matching-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? -1))->toBe(0);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->count();
expect($findings)->toBe(0);
});
// --- T042: Summary counts severity breakdown tests ---
it('writes severity breakdown in summary_counts', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// 2 baseline items: one will be missing (high), one will be different (medium)
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'missing-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'missing-content'),
'meta_jsonb' => ['display_name' => 'Missing Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'changed-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'original-content'),
'meta_jsonb' => ['display_name' => 'Changed Policy'],
]);
// Tenant only has changed-uuid with different content + extra-uuid (unexpected)
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'changed-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['modified_content' => true],
'display_name' => 'Changed Policy',
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'extra-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['extra_content' => true],
'display_name' => 'Extra Policy',
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? -1))->toBe(3);
expect((int) ($counts['high'] ?? -1))->toBe(1); // missing-uuid
expect((int) ($counts['medium'] ?? -1))->toBe(1); // changed-uuid
expect((int) ($counts['low'] ?? -1))->toBe(1); // extra-uuid
});
it('writes result context with findings breakdown', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
// One missing policy
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'gone-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'gone-content'),
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_compare',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
$context = is_array($run->context) ? $run->context : [];
$result = $context['result'] ?? [];
expect($result)->toHaveKey('findings_total');
expect($result)->toHaveKey('findings_upserted');
expect($result)->toHaveKey('severity_breakdown');
expect((int) $result['findings_total'])->toBe(1);
expect((int) $result['findings_upserted'])->toBe(1);
});

View File

@ -0,0 +1,178 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineReasonCodes;
use Illuminate\Support\Facades\Queue;
// --- T040: Compare precondition 422 tests ---
it('rejects compare when tenant has no baseline assignment', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('rejects compare when assigned profile is in draft status', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->create([
'workspace_id' => $tenant->workspace_id,
'status' => BaselineProfile::STATUS_DRAFT,
]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('rejects compare when assigned profile is archived [EC-001]', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->archived()->create([
'workspace_id' => $tenant->workspace_id,
]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
});
it('rejects compare when profile has no active snapshot', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'active_snapshot_id' => null,
]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
});
it('enqueues compare successfully when all preconditions are met', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeTrue();
expect($result)->toHaveKey('run');
/** @var OperationRun $run */
$run = $result['run'];
expect($run->type)->toBe('baseline_compare');
expect($run->status)->toBe('queued');
$context = is_array($run->context) ? $run->context : [];
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
expect($context['baseline_snapshot_id'])->toBe((int) $snapshot->getKey());
Queue::assertPushed(CompareBaselineToTenantJob::class);
});
// --- EC-004: Concurrent compare reuses active run ---
it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result1 = $service->startCompare($tenant, $user);
$result2 = $service->startCompare($tenant, $user);
expect($result1['ok'])->toBeTrue();
expect($result2['ok'])->toBeTrue();
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1);
});

View 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();
});
});

View File

@ -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) {