## Summary - introduce the governance subject taxonomy registry and canonical Baseline Scope V2 normalization and persistence - update baseline profile Filament surfaces, validation, capture/compare gating, and add the optional scope backfill command with audit logging - add focused unit, feature, Filament, and browser smoke coverage for save-forward behavior, operation truth, authorization continuity, and invalid-scope rendering - remove the duplicate legacy spec plan under `specs/001-governance-subject-taxonomy/plan.md` ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec202GovernanceSubjectTaxonomySmokeTest.php` - focused Spec 202 regression pack: `56 passed (300 assertions)` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - no schema migration required - no new Filament asset registration required - branch includes the final browser smoke test coverage for the current feature Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #232
204 lines
6.8 KiB
PHP
204 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use InvalidArgumentException;
|
|
use Illuminate\Console\Command;
|
|
|
|
class BackfillBaselineScopeV2 extends Command
|
|
{
|
|
protected $signature = 'tenantpilot:baseline-scope-v2:backfill
|
|
{--workspace= : Restrict to a workspace id}
|
|
{--chunk=100 : Chunk size for large scans}
|
|
{--write : Persist canonical V2 scope rows}
|
|
{--confirm-write : Required acknowledgement before mutating rows}';
|
|
|
|
protected $description = 'Preview or commit canonical Baseline Scope V2 backfill for legacy baseline profile rows.';
|
|
|
|
public function handle(WorkspaceAuditLogger $auditLogger): int
|
|
{
|
|
$write = (bool) $this->option('write');
|
|
|
|
if ($write && ! (bool) $this->option('confirm-write')) {
|
|
$this->error('Explicit write confirmation required. Re-run with --write --confirm-write.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$workspaceOption = $this->option('workspace');
|
|
|
|
if ($workspaceOption !== null && ! is_numeric($workspaceOption)) {
|
|
$this->error('The --workspace option must be a numeric workspace id.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$workspaceId = is_numeric($workspaceOption) ? (int) $workspaceOption : null;
|
|
$scan = $this->scanCandidates(
|
|
chunkSize: max(1, (int) $this->option('chunk')),
|
|
workspaceId: $workspaceId,
|
|
);
|
|
|
|
$mode = $write ? 'commit' : 'preview';
|
|
$candidateCount = count($scan['candidates']);
|
|
$invalidCount = count($scan['invalid']);
|
|
|
|
$this->info(sprintf('Mode: %s', $mode));
|
|
$this->info('Scope surface: baseline_profiles_only');
|
|
$this->info(sprintf('Candidate count: %d', $candidateCount));
|
|
|
|
foreach ($scan['candidates'] as $candidate) {
|
|
$this->line(sprintf(' - %s', $candidate['summary']));
|
|
}
|
|
|
|
if ($invalidCount > 0) {
|
|
$this->warn(sprintf('Invalid rows: %d', $invalidCount));
|
|
|
|
foreach ($scan['invalid'] as $invalidRow) {
|
|
$this->warn(sprintf(' - %s', $invalidRow));
|
|
}
|
|
}
|
|
|
|
if ($candidateCount === 0) {
|
|
$this->info('No baseline profile scope rows require backfill.');
|
|
$this->info('Rewritten count: 0');
|
|
$this->info('Audit logged: no');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
if ($write && $invalidCount > 0) {
|
|
$this->error('Backfill aborted because invalid scope rows were detected. Resolve them before committing.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if (! $write) {
|
|
$this->info('Rewritten count: 0');
|
|
$this->info('Audit logged: no');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$rewrittenCount = 0;
|
|
$auditLogged = false;
|
|
|
|
foreach ($scan['candidates'] as $candidate) {
|
|
$profile = BaselineProfile::query()
|
|
->with('workspace')
|
|
->find($candidate['id']);
|
|
|
|
if (! $profile instanceof BaselineProfile) {
|
|
continue;
|
|
}
|
|
|
|
$before = $profile->rawScopeJsonb();
|
|
$after = $profile->canonicalScopeJsonb();
|
|
|
|
if (! $profile->rewriteScopeToCanonicalV2()) {
|
|
continue;
|
|
}
|
|
|
|
$workspace = $profile->workspace;
|
|
|
|
if ($workspace instanceof Workspace) {
|
|
$auditLogger->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::BaselineProfileScopeBackfilled,
|
|
context: [
|
|
'metadata' => [
|
|
'source' => 'tenantpilot:baseline-scope-v2:backfill',
|
|
'workspace_id' => (int) $profile->workspace_id,
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'mode' => 'commit',
|
|
'before_scope' => $before,
|
|
'after_scope' => $after,
|
|
],
|
|
],
|
|
resourceType: 'baseline_profile',
|
|
resourceId: (string) $profile->getKey(),
|
|
targetLabel: (string) $profile->name,
|
|
summary: sprintf('Baseline profile "%s" scope backfilled to canonical V2.', (string) $profile->name),
|
|
);
|
|
|
|
$auditLogged = true;
|
|
}
|
|
|
|
$rewrittenCount++;
|
|
}
|
|
|
|
$this->info(sprintf('Rewritten count: %d', $rewrittenCount));
|
|
$this->info(sprintf('Audit logged: %s', $auditLogged ? 'yes' : 'no'));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @return array{candidates: list<array{id: int, summary: string}>, invalid: list<string>}
|
|
*/
|
|
private function scanCandidates(int $chunkSize, ?int $workspaceId = null): array
|
|
{
|
|
$candidates = [];
|
|
$invalid = [];
|
|
|
|
BaselineProfile::query()
|
|
->when(
|
|
$workspaceId !== null,
|
|
fn ($query) => $query->where('workspace_id', $workspaceId),
|
|
)
|
|
->orderBy('id')
|
|
->chunkById($chunkSize, function ($profiles) use (&$candidates, &$invalid): void {
|
|
foreach ($profiles as $profile) {
|
|
if (! $profile instanceof BaselineProfile) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (! $profile->requiresScopeSaveForward()) {
|
|
continue;
|
|
}
|
|
|
|
$candidates[] = [
|
|
'id' => (int) $profile->getKey(),
|
|
'summary' => $this->candidateSummary($profile),
|
|
];
|
|
} catch (InvalidArgumentException $exception) {
|
|
$invalid[] = sprintf(
|
|
'#%d "%s": %s',
|
|
(int) $profile->getKey(),
|
|
(string) $profile->name,
|
|
$exception->getMessage(),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
return [
|
|
'candidates' => $candidates,
|
|
'invalid' => $invalid,
|
|
];
|
|
}
|
|
|
|
private function candidateSummary(BaselineProfile $profile): string
|
|
{
|
|
$groupSummary = collect($profile->normalizedScope()->summaryGroups())
|
|
->map(function (array $group): string {
|
|
return $group['group_label'].': '.implode(', ', $group['selected_subject_types']);
|
|
})
|
|
->implode('; ');
|
|
|
|
return sprintf(
|
|
'#%d workspace=%d "%s" => %s',
|
|
(int) $profile->getKey(),
|
|
(int) $profile->workspace_id,
|
|
(string) $profile->name,
|
|
$groupSummary,
|
|
);
|
|
}
|
|
} |