TenantAtlas/apps/platform/app/Console/Commands/BackfillBaselineScopeV2.php
ahmido 7541b1eb41 Spec 202: implement governance subject taxonomy and baseline scope V2 (#232)
## 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
2026-04-13 15:33:33 +00:00

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