From 53dc89e6ef01547a0a864f2de08d4505b63b91c0 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 5 Feb 2026 21:44:19 +0000 Subject: [PATCH] Spec 075: Verification Checklist Framework V1.5 (fingerprint + acknowledgements) (#93) Implements Spec 075 (V1.5) on top of Spec 074. Highlights - Deterministic report fingerprint (sha256) + previous_report_id linkage - Viewer change indicator: "No changes" vs "Changed" when previous exists - Check acknowledgements (fail|warn|block) with capability-first auth, confirmation, and audit event - Verify-step UX polish (issues-first, primary CTA) Testing - Focused Pest coverage for fingerprint, previous resolver, change indicator, acknowledgements, badge semantics, DB-only viewer guard. Notes - Viewing remains DB-only (no external calls while rendering). Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/93 --- .../ManagedTenantOnboardingWizard.php | 241 ++++++- .../Resources/OperationRunResource.php | 61 ++ .../VerificationReportChangeIndicator.php | 47 ++ .../Support/VerificationReportViewer.php | 48 ++ .../VerificationCheckAcknowledgement.php | 41 ++ app/Services/Auth/RoleCapabilityMap.php | 2 + ...erificationCheckAcknowledgementService.php | 187 ++++++ app/Support/Audit/AuditActionId.php | 1 + app/Support/Audit/AuditContextSanitizer.php | 2 +- app/Support/Auth/Capabilities.php | 3 + .../PreviousVerificationReportResolver.php | 63 ++ .../VerificationReportFingerprint.php | 96 +++ .../VerificationReportSanitizer.php | 44 +- .../Verification/VerificationReportSchema.php | 26 +- .../Verification/VerificationReportWriter.php | 12 +- ...erificationCheckAcknowledgementFactory.php | 37 ++ ...ification_check_acknowledgements_table.php | 37 ++ phpunit.xml | 1 + .../verification-report-viewer.blade.php | 580 +++++++++++++---- ...t-onboarding-verification-report.blade.php | 610 +++++++++++++++--- .../checklists/requirements.md | 34 + .../acknowledge-check.request.schema.json | 31 + ...fication-check-acknowledgement.schema.json | 48 ++ .../verification-report.v1_5.schema.json | 147 +++++ specs/075-verification-v1-5/data-model.md | 114 ++++ specs/075-verification-v1-5/plan.md | 150 +++++ specs/075-verification-v1-5/quickstart.md | 47 ++ specs/075-verification-v1-5/research.md | 119 ++++ specs/075-verification-v1-5/spec.md | 225 +++++++ specs/075-verification-v1-5/tasks.md | 172 +++++ .../Badges/VerificationBadgeSemanticsTest.php | 47 ++ .../Onboarding/OnboardingVerificationTest.php | 23 +- .../OnboardingVerificationV1_5UxTest.php | 222 +++++++ ...PreviousVerificationReportResolverTest.php | 124 ++++ .../VerificationCheckAcknowledgementTest.php | 180 ++++++ .../VerificationReportFingerprintTest.php | 47 ++ .../VerificationReportRedactionTest.php | 31 +- .../VerificationReportViewerDbOnlyTest.php | 85 +++ tests/Unit/AuditContextSanitizerTest.php | 20 + 39 files changed, 3757 insertions(+), 248 deletions(-) create mode 100644 app/Filament/Support/VerificationReportChangeIndicator.php create mode 100644 app/Models/VerificationCheckAcknowledgement.php create mode 100644 app/Services/Verification/VerificationCheckAcknowledgementService.php create mode 100644 app/Support/Verification/PreviousVerificationReportResolver.php create mode 100644 app/Support/Verification/VerificationReportFingerprint.php create mode 100644 database/factories/VerificationCheckAcknowledgementFactory.php create mode 100644 database/migrations/2026_02_05_000001_create_verification_check_acknowledgements_table.php create mode 100644 specs/075-verification-v1-5/checklists/requirements.md create mode 100644 specs/075-verification-v1-5/contracts/acknowledge-check.request.schema.json create mode 100644 specs/075-verification-v1-5/contracts/verification-check-acknowledgement.schema.json create mode 100644 specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json create mode 100644 specs/075-verification-v1-5/data-model.md create mode 100644 specs/075-verification-v1-5/plan.md create mode 100644 specs/075-verification-v1-5/quickstart.md create mode 100644 specs/075-verification-v1-5/research.md create mode 100644 specs/075-verification-v1-5/spec.md create mode 100644 specs/075-verification-v1-5/tasks.md create mode 100644 tests/Feature/Badges/VerificationBadgeSemanticsTest.php create mode 100644 tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php create mode 100644 tests/Feature/Verification/PreviousVerificationReportResolverTest.php create mode 100644 tests/Feature/Verification/VerificationCheckAcknowledgementTest.php create mode 100644 tests/Feature/Verification/VerificationReportFingerprintTest.php create mode 100644 tests/Unit/AuditContextSanitizerTest.php diff --git a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index e40446d..d9bb0a6 100644 --- a/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -5,6 +5,8 @@ namespace App\Filament\Pages\Workspaces; use App\Filament\Pages\TenantDashboard; +use App\Filament\Support\VerificationReportChangeIndicator; +use App\Filament\Support\VerificationReportViewer; use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderConnectionHealthCheckJob; use App\Jobs\ProviderInventorySyncJob; @@ -14,6 +16,7 @@ use App\Models\TenantMembership; use App\Models\TenantOnboardingSession; use App\Models\User; +use App\Models\VerificationCheckAcknowledgement; use App\Models\Workspace; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\TenantMembershipManager; @@ -21,6 +24,7 @@ use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationRegistry; use App\Services\Providers\ProviderOperationStartGate; +use App\Services\Verification\VerificationCheckAcknowledgementService; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeCatalog; @@ -28,6 +32,7 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\Verification\VerificationCheckStatus; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Forms\Components\CheckboxList; @@ -51,6 +56,7 @@ use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; +use InvalidArgumentException; use RuntimeException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -286,7 +292,7 @@ public function content(Schema $schema): Schema SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') - ->visible(fn (): bool => $this->managedTenant instanceof Tenant) + ->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress') ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ? null @@ -294,7 +300,7 @@ public function content(Schema $schema): Schema ->action(fn () => $this->startVerification()), Action::make('wizardRefreshVerification') ->label('Refresh') - ->visible(fn (): bool => $this->verificationRunUrl() !== null) + ->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationStatus() === 'in_progress') ->action(fn () => $this->refreshVerificationStatus()), ]), ViewField::make('verification_report') @@ -629,7 +635,22 @@ private function verificationRunUrl(): ?string } /** - * @return array{run: array|null, runUrl: string|null} + * @return array{ + * run: array|null, + * runUrl: string|null, + * report: array|null, + * fingerprint: string|null, + * changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null, + * previousRunUrl: string|null, + * canAcknowledge: bool, + * acknowledgements: array + * } */ private function verificationReportViewData(): array { @@ -640,9 +661,54 @@ private function verificationReportViewData(): array return [ 'run' => null, 'runUrl' => $runUrl, + 'report' => null, + 'fingerprint' => null, + 'changeIndicator' => null, + 'previousRunUrl' => null, + 'canAcknowledge' => false, + 'acknowledgements' => [], ]; } + $report = VerificationReportViewer::report($run); + $fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null; + + $changeIndicator = VerificationReportChangeIndicator::forRun($run); + $previousRunUrl = $changeIndicator === null + ? null + : $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']); + + $user = auth()->user(); + $canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant + ? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant) + : false; + + $acknowledgements = VerificationCheckAcknowledgement::query() + ->where('tenant_id', (int) $run->tenant_id) + ->where('workspace_id', (int) $run->workspace_id) + ->where('operation_run_id', (int) $run->getKey()) + ->with('acknowledgedByUser') + ->get() + ->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array { + $user = $ack->acknowledgedByUser; + + return [ + (string) $ack->check_key => [ + 'check_key' => (string) $ack->check_key, + 'ack_reason' => (string) $ack->ack_reason, + 'acknowledged_at' => $ack->acknowledged_at?->toJSON(), + 'expires_at' => $ack->expires_at?->toJSON(), + 'acknowledged_by' => $user instanceof User + ? [ + 'id' => (int) $user->getKey(), + 'name' => (string) $user->name, + ] + : null, + ], + ]; + }) + ->all(); + $context = is_array($run->context ?? null) ? $run->context : []; $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; @@ -650,19 +716,162 @@ private function verificationReportViewData(): array $failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : []; return [ - 'run' => [ - 'id' => (int) $run->getKey(), - 'type' => (string) $run->type, - 'status' => (string) $run->status, - 'outcome' => (string) $run->outcome, - 'initiator_name' => (string) $run->initiator_name, - 'started_at' => $run->started_at?->toJSON(), - 'completed_at' => $run->completed_at?->toJSON(), - 'target_scope' => $targetScope, - 'failures' => $failures, - ], - 'runUrl' => $runUrl, - ]; + 'run' => [ + 'id' => (int) $run->getKey(), + 'type' => (string) $run->type, + 'status' => (string) $run->status, + 'outcome' => (string) $run->outcome, + 'initiator_name' => (string) $run->initiator_name, + 'started_at' => $run->started_at?->toJSON(), + 'completed_at' => $run->completed_at?->toJSON(), + 'target_scope' => $targetScope, + 'failures' => $failures, + ], + 'runUrl' => $runUrl, + 'report' => $report, + 'fingerprint' => $fingerprint, + 'changeIndicator' => $changeIndicator, + 'previousRunUrl' => $previousRunUrl, + 'canAcknowledge' => $canAcknowledge, + 'acknowledgements' => $acknowledgements, + ]; + } + + public function acknowledgeVerificationCheckAction(): Action + { + return Action::make('acknowledgeVerificationCheck') + ->label('Acknowledge') + ->color('gray') + ->requiresConfirmation() + ->modalHeading('Acknowledge issue') + ->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.') + ->form([ + Textarea::make('ack_reason') + ->label('Reason') + ->required() + ->maxLength(160) + ->rows(3), + TextInput::make('expires_at') + ->label('Expiry (optional)') + ->helperText('Optional timestamp (informational only).') + ->maxLength(64), + ]) + ->action(function (array $data, array $arguments): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $this->managedTenant instanceof Tenant) { + abort(404); + } + + $tenant = $this->managedTenant->fresh(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $run = $this->verificationRun(); + + if (! $run instanceof OperationRun) { + throw new NotFoundHttpException; + } + + $checkKey = (string) ($arguments['check_key'] ?? ''); + $ackReason = (string) ($data['ack_reason'] ?? ''); + $expiresAt = $data['expires_at'] ?? null; + $expiresAt = is_string($expiresAt) ? $expiresAt : null; + + try { + app(VerificationCheckAcknowledgementService::class)->acknowledge( + tenant: $tenant, + run: $run, + checkKey: $checkKey, + ackReason: $ackReason, + expiresAt: $expiresAt, + actor: $user, + ); + } catch (InvalidArgumentException $e) { + Notification::make() + ->title('Unable to acknowledge') + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Issue acknowledged') + ->success() + ->send(); + }) + ->visible(function (array $arguments): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + if (! $this->managedTenant instanceof Tenant) { + return false; + } + + if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) { + return false; + } + + $run = $this->verificationRun(); + + if (! $run instanceof OperationRun) { + return false; + } + + $checkKey = trim((string) ($arguments['check_key'] ?? '')); + + if ($checkKey === '') { + return false; + } + + $ackExists = VerificationCheckAcknowledgement::query() + ->where('operation_run_id', (int) $run->getKey()) + ->where('check_key', $checkKey) + ->exists(); + + if ($ackExists) { + return false; + } + + $report = VerificationReportViewer::report($run); + + if (! is_array($report)) { + return false; + } + + $checks = $report['checks'] ?? null; + $checks = is_array($checks) ? $checks : []; + + foreach ($checks as $check) { + if (! is_array($check)) { + continue; + } + + if (($check['key'] ?? null) !== $checkKey) { + continue; + } + + $status = $check['status'] ?? null; + + return is_string($status) && in_array($status, [ + VerificationCheckStatus::Fail->value, + VerificationCheckStatus::Warn->value, + ], true); + } + + return false; + }); } private function bootstrapRunsLabel(): string diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 1168a13..608ae3c 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -3,12 +3,16 @@ namespace App\Filament\Resources; use App\Filament\Resources\OperationRunResource\Pages; +use App\Filament\Support\VerificationReportChangeIndicator; use App\Filament\Support\VerificationReportViewer; use App\Models\OperationRun; use App\Models\Tenant; +use App\Models\User; +use App\Models\VerificationCheckAcknowledgement; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationCatalog; +use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\RunDetailPolling; @@ -143,6 +147,63 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.components.verification-report-viewer') ->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record)) + ->viewData(function (OperationRun $record): array { + $report = VerificationReportViewer::report($record); + $fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null; + + $changeIndicator = VerificationReportChangeIndicator::forRun($record); + + $previousRunUrl = null; + + if ($changeIndicator !== null) { + $tenant = Tenant::current(); + + $previousRunUrl = $tenant instanceof Tenant + ? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant) + : OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']); + } + + $acknowledgements = VerificationCheckAcknowledgement::query() + ->where('tenant_id', (int) ($record->tenant_id ?? 0)) + ->where('workspace_id', (int) ($record->workspace_id ?? 0)) + ->where('operation_run_id', (int) $record->getKey()) + ->with('acknowledgedByUser') + ->get() + ->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array { + $user = $ack->acknowledgedByUser; + + return [ + (string) $ack->check_key => [ + 'check_key' => (string) $ack->check_key, + 'ack_reason' => (string) $ack->ack_reason, + 'acknowledged_at' => $ack->acknowledged_at?->toJSON(), + 'expires_at' => $ack->expires_at?->toJSON(), + 'acknowledged_by' => $user instanceof User + ? [ + 'id' => (int) $user->getKey(), + 'name' => (string) $user->name, + ] + : null, + ], + ]; + }) + ->all(); + + return [ + 'run' => [ + 'id' => (int) $record->getKey(), + 'type' => (string) $record->type, + 'status' => (string) $record->status, + 'outcome' => (string) $record->outcome, + 'started_at' => $record->started_at?->toJSON(), + 'completed_at' => $record->completed_at?->toJSON(), + ], + 'fingerprint' => $fingerprint, + 'changeIndicator' => $changeIndicator, + 'previousRunUrl' => $previousRunUrl, + 'acknowledgements' => $acknowledgements, + ]; + }) ->columnSpanFull(), ]) ->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record)) diff --git a/app/Filament/Support/VerificationReportChangeIndicator.php b/app/Filament/Support/VerificationReportChangeIndicator.php new file mode 100644 index 0000000..1dbd4a3 --- /dev/null +++ b/app/Filament/Support/VerificationReportChangeIndicator.php @@ -0,0 +1,47 @@ + $currentFingerprint === $previousFingerprint ? 'no_changes' : 'changed', + 'previous_report_id' => (int) $previousRun->getKey(), + ]; + } +} + diff --git a/app/Filament/Support/VerificationReportViewer.php b/app/Filament/Support/VerificationReportViewer.php index aaa766f..90f9391 100644 --- a/app/Filament/Support/VerificationReportViewer.php +++ b/app/Filament/Support/VerificationReportViewer.php @@ -5,6 +5,7 @@ namespace App\Filament\Support; use App\Models\OperationRun; +use App\Support\Verification\VerificationReportFingerprint; use App\Support\Verification\VerificationReportSanitizer; use App\Support\Verification\VerificationReportSchema; @@ -31,6 +32,53 @@ public static function report(OperationRun $run): ?array return $report; } + public static function previousReportId(array $report): ?int + { + $previousReportId = $report['previous_report_id'] ?? null; + + if (is_int($previousReportId) && $previousReportId > 0) { + return $previousReportId; + } + + if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) { + return (int) trim($previousReportId); + } + + return null; + } + + public static function fingerprint(array $report): ?string + { + $fingerprint = $report['fingerprint'] ?? null; + + if (is_string($fingerprint)) { + $fingerprint = strtolower(trim($fingerprint)); + + if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) { + return $fingerprint; + } + } + + return VerificationReportFingerprint::forReport($report); + } + + public static function previousRun(OperationRun $run, array $report): ?OperationRun + { + $previousReportId = self::previousReportId($report); + + if ($previousReportId === null) { + return null; + } + + $previous = OperationRun::query() + ->whereKey($previousReportId) + ->where('tenant_id', (int) $run->tenant_id) + ->where('workspace_id', (int) $run->workspace_id) + ->first(); + + return $previous instanceof OperationRun ? $previous : null; + } + public static function shouldRenderForRun(OperationRun $run): bool { $context = is_array($run->context) ? $run->context : []; diff --git a/app/Models/VerificationCheckAcknowledgement.php b/app/Models/VerificationCheckAcknowledgement.php new file mode 100644 index 0000000..69dbda9 --- /dev/null +++ b/app/Models/VerificationCheckAcknowledgement.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'expires_at' => 'datetime', + 'acknowledged_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class); + } + + public function acknowledgedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'acknowledged_by_user_id'); + } +} + diff --git a/app/Services/Auth/RoleCapabilityMap.php b/app/Services/Auth/RoleCapabilityMap.php index 04bf870..5cba9f8 100644 --- a/app/Services/Auth/RoleCapabilityMap.php +++ b/app/Services/Auth/RoleCapabilityMap.php @@ -21,6 +21,7 @@ class RoleCapabilityMap Capabilities::TENANT_SYNC, Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, + Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_MANAGE, @@ -44,6 +45,7 @@ class RoleCapabilityMap Capabilities::TENANT_SYNC, Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, + Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, Capabilities::TENANT_MEMBERSHIP_VIEW, diff --git a/app/Services/Verification/VerificationCheckAcknowledgementService.php b/app/Services/Verification/VerificationCheckAcknowledgementService.php new file mode 100644 index 0000000..52348e4 --- /dev/null +++ b/app/Services/Verification/VerificationCheckAcknowledgementService.php @@ -0,0 +1,187 @@ +canAccessTenant($tenant)) { + throw new NotFoundHttpException; + } + + Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant); + + if ((int) $run->tenant_id !== (int) $tenant->getKey()) { + throw new NotFoundHttpException; + } + + if ((int) $run->workspace_id !== (int) $tenant->workspace_id) { + throw new NotFoundHttpException; + } + + $checkKey = trim($checkKey); + if ($checkKey === '') { + throw new InvalidArgumentException('check_key is required.'); + } + + $ackReason = trim($ackReason); + if ($ackReason === '') { + throw new InvalidArgumentException('ack_reason is required.'); + } + + if (mb_strlen($ackReason) > 160) { + throw new InvalidArgumentException('ack_reason must be at most 160 characters.'); + } + + $report = $this->reportForRun($run); + $check = $this->findCheckByKey($report, $checkKey); + + $status = $check['status'] ?? null; + + if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) { + throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.'); + } + + $reasonCode = $check['reason_code'] ?? null; + if (! is_string($reasonCode) || trim($reasonCode) === '') { + throw new InvalidArgumentException('Check reason_code is required.'); + } + + $expiresAtParsed = null; + + if ($expiresAt !== null && trim($expiresAt) !== '') { + try { + $expiresAtParsed = CarbonImmutable::parse($expiresAt); + } catch (\Throwable) { + throw new InvalidArgumentException('expires_at must be a valid date-time.'); + } + + if ($expiresAtParsed->isBefore(CarbonImmutable::now())) { + throw new InvalidArgumentException('expires_at must be in the future.'); + } + } + + $acknowledgedAt = CarbonImmutable::now(); + + try { + $ack = VerificationCheckAcknowledgement::create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'check_key' => $checkKey, + 'ack_reason' => $ackReason, + 'expires_at' => $expiresAtParsed, + 'acknowledged_at' => $acknowledgedAt, + 'acknowledged_by_user_id' => (int) $actor->getKey(), + ]); + } catch (QueryException $e) { + $ack = VerificationCheckAcknowledgement::query() + ->where('operation_run_id', (int) $run->getKey()) + ->where('check_key', $checkKey) + ->first(); + + if (! $ack instanceof VerificationCheckAcknowledgement) { + throw $e; + } + + return $ack; + } + + if ($ack->wasRecentlyCreated) { + $workspace = $tenant->workspace; + + if ($workspace !== null) { + $this->audit->log( + workspace: $workspace, + action: AuditActionId::VerificationCheckAcknowledged->value, + context: [ + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'report_id' => (int) $run->getKey(), + 'flow' => (string) $run->type, + 'check_key' => $checkKey, + 'reason_code' => $reasonCode, + ], + actor: $actor, + resourceType: 'operation_run', + resourceId: (string) $run->getKey(), + ); + } + } + + return $ack; + } + + /** + * @return array + */ + private function reportForRun(OperationRun $run): array + { + $context = is_array($run->context) ? $run->context : []; + $report = $context['verification_report'] ?? null; + + if (! is_array($report)) { + throw new InvalidArgumentException('Verification report is missing.'); + } + + $report = VerificationReportSanitizer::sanitizeReport($report); + + if (! VerificationReportSchema::isValidReport($report)) { + throw new InvalidArgumentException('Verification report is invalid.'); + } + + return $report; + } + + /** + * @param array $report + * @return array + */ + private function findCheckByKey(array $report, string $checkKey): array + { + $checks = $report['checks'] ?? null; + $checks = is_array($checks) ? $checks : []; + + foreach ($checks as $check) { + if (! is_array($check)) { + continue; + } + + if (($check['key'] ?? null) === $checkKey) { + return $check; + } + } + + throw new InvalidArgumentException('Check not found in verification report.'); + } +} + diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index a50d1f0..4f180fc 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -29,4 +29,5 @@ enum AuditActionId: string case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start'; case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation'; case VerificationCompleted = 'verification.completed'; + case VerificationCheckAcknowledged = 'verification.check_acknowledged'; } diff --git a/app/Support/Audit/AuditContextSanitizer.php b/app/Support/Audit/AuditContextSanitizer.php index 3616fde..35ce3ed 100644 --- a/app/Support/Audit/AuditContextSanitizer.php +++ b/app/Support/Audit/AuditContextSanitizer.php @@ -57,7 +57,7 @@ private static function sanitizeString(string $value): string return self::REDACTED; } - if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) { + if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) { return self::REDACTED; } diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 117446e..ac2eb9a 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -61,6 +61,9 @@ class Capabilities // Findings public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge'; + // Verification + public const TENANT_VERIFICATION_ACKNOWLEDGE = 'tenant_verification.acknowledge'; + // Tenant memberships public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view'; diff --git a/app/Support/Verification/PreviousVerificationReportResolver.php b/app/Support/Verification/PreviousVerificationReportResolver.php new file mode 100644 index 0000000..bc58cc4 --- /dev/null +++ b/app/Support/Verification/PreviousVerificationReportResolver.php @@ -0,0 +1,63 @@ +getKey(); + + if (! is_int($runId) || $runId <= 0) { + return null; + } + + $providerConnectionId = self::providerConnectionId($run); + + $query = OperationRun::query() + ->where('tenant_id', (int) $run->tenant_id) + ->where('workspace_id', (int) $run->workspace_id) + ->where('type', (string) $run->type) + ->where('run_identity_hash', (string) $run->run_identity_hash) + ->where('status', OperationRunStatus::Completed->value) + ->where('id', '<', $runId) + ->orderByDesc('id'); + + if ($providerConnectionId !== null) { + $query->where('context->provider_connection_id', $providerConnectionId); + } else { + $query->whereNull('context->provider_connection_id'); + } + + $previousId = $query->value('id'); + + return is_int($previousId) ? $previousId : null; + } + + private static function providerConnectionId(OperationRun $run): ?int + { + $context = $run->context; + + if (! is_array($context)) { + return null; + } + + $providerConnectionId = $context['provider_connection_id'] ?? null; + + if (is_int($providerConnectionId)) { + return $providerConnectionId; + } + + if (is_string($providerConnectionId) && ctype_digit(trim($providerConnectionId))) { + return (int) trim($providerConnectionId); + } + + return null; + } +} + diff --git a/app/Support/Verification/VerificationReportFingerprint.php b/app/Support/Verification/VerificationReportFingerprint.php new file mode 100644 index 0000000..a750590 --- /dev/null +++ b/app/Support/Verification/VerificationReportFingerprint.php @@ -0,0 +1,96 @@ +> $checks + */ + public static function forChecks(array $checks): string + { + $tuples = []; + + foreach ($checks as $check) { + if (! is_array($check)) { + continue; + } + + $key = self::normalizeKey($check['key'] ?? null); + $status = self::normalizeEnumString($check['status'] ?? null); + $reasonCode = self::normalizeEnumString($check['reason_code'] ?? null); + + $blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false; + + $severity = $check['severity'] ?? null; + $severity = is_string($severity) ? trim($severity) : ''; + + if ($severity === '') { + $severity = ''; + } else { + $severity = strtolower($severity); + } + + $tuples[] = [ + 'key' => $key, + 'tuple' => implode('|', [ + $key, + $status, + $blocking ? '1' : '0', + $reasonCode, + $severity, + ]), + ]; + } + + usort($tuples, static function (array $a, array $b): int { + $keyComparison = $a['key'] <=> $b['key']; + + if ($keyComparison !== 0) { + return $keyComparison; + } + + return $a['tuple'] <=> $b['tuple']; + }); + + $payload = implode("\n", array_map(static fn (array $item): string => (string) $item['tuple'], $tuples)); + + return hash('sha256', $payload); + } + + /** + * @param array $report + */ + public static function forReport(array $report): string + { + $checks = $report['checks'] ?? null; + $checks = is_array($checks) ? $checks : []; + + /** @var array> $checks */ + return self::forChecks($checks); + } + + private static function normalizeKey(mixed $value): string + { + if (! is_string($value)) { + return ''; + } + + $value = trim($value); + + return $value === '' ? '' : $value; + } + + private static function normalizeEnumString(mixed $value): string + { + if (! is_string($value)) { + return ''; + } + + $value = trim($value); + + return $value === '' ? '' : strtolower($value); + } +} diff --git a/app/Support/Verification/VerificationReportSanitizer.php b/app/Support/Verification/VerificationReportSanitizer.php index ad9ddda..d001b8e 100644 --- a/app/Support/Verification/VerificationReportSanitizer.php +++ b/app/Support/Verification/VerificationReportSanitizer.php @@ -39,6 +39,40 @@ public static function sanitizeReport(array $report): array $sanitized['generated_at'] = $generatedAt; } + if (array_key_exists('fingerprint', $report)) { + $fingerprint = $report['fingerprint']; + + if (is_string($fingerprint)) { + $fingerprint = strtolower(trim($fingerprint)); + + if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) { + $sanitized['fingerprint'] = $fingerprint; + } + } + } + + if (array_key_exists('previous_report_id', $report)) { + $previousReportId = $report['previous_report_id']; + + if ($previousReportId === null || is_int($previousReportId)) { + $sanitized['previous_report_id'] = $previousReportId; + } elseif (is_string($previousReportId)) { + $previousReportId = trim($previousReportId); + + if ($previousReportId === '') { + $sanitized['previous_report_id'] = null; + } elseif (ctype_digit($previousReportId)) { + $sanitized['previous_report_id'] = (int) $previousReportId; + } else { + $previousReportId = self::sanitizeShortString($previousReportId, fallback: null); + + if ($previousReportId !== null) { + $sanitized['previous_report_id'] = $previousReportId; + } + } + } + } + if (is_array($report['identity'] ?? null)) { $identity = self::sanitizeIdentity((array) $report['identity']); @@ -164,8 +198,14 @@ private static function sanitizeChecks(array $checks): ?array continue; } - $severity = $check['severity'] ?? null; - if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) { + $severityRaw = $check['severity'] ?? null; + if (! is_string($severityRaw)) { + continue; + } + + $severity = strtolower(trim($severityRaw)); + + if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) { continue; } diff --git a/app/Support/Verification/VerificationReportSchema.php b/app/Support/Verification/VerificationReportSchema.php index f6648d5..d420579 100644 --- a/app/Support/Verification/VerificationReportSchema.php +++ b/app/Support/Verification/VerificationReportSchema.php @@ -6,7 +6,7 @@ final class VerificationReportSchema { - public const string CURRENT_SCHEMA_VERSION = '1.0.0'; + public const string CURRENT_SCHEMA_VERSION = '1.5.0'; /** * @return array|null @@ -78,6 +78,22 @@ public static function isValidReport(array $report): bool } } + if (array_key_exists('fingerprint', $report)) { + $fingerprint = $report['fingerprint']; + + if (! is_string($fingerprint) || ! preg_match('/^[a-f0-9]{64}$/', $fingerprint)) { + return false; + } + } + + if (array_key_exists('previous_report_id', $report)) { + $previousReportId = $report['previous_report_id']; + + if ($previousReportId !== null && ! is_int($previousReportId) && ! self::isNonEmptyString($previousReportId)) { + return false; + } + } + return true; } @@ -137,7 +153,13 @@ private static function isValidCheckResult(array $check): bool } $severity = $check['severity'] ?? null; - if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) { + if (! is_string($severity)) { + return false; + } + + $severity = trim($severity); + + if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) { return false; } diff --git a/app/Support/Verification/VerificationReportWriter.php b/app/Support/Verification/VerificationReportWriter.php index 2ea3ba6..78d5515 100644 --- a/app/Support/Verification/VerificationReportWriter.php +++ b/app/Support/Verification/VerificationReportWriter.php @@ -35,6 +35,8 @@ public static function write(OperationRun $run, array $checks, array $identity = $flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown'; $report = self::build($flow, $checks, $identity); + $report['previous_report_id'] = PreviousVerificationReportResolver::resolvePreviousReportId($run); + $report = VerificationReportSanitizer::sanitizeReport($report); if (! VerificationReportSchema::isValidReport($report)) { @@ -75,6 +77,8 @@ public static function build(string $flow, array $checks, array $identity = []): 'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION, 'flow' => $flow, 'generated_at' => now()->toISOString(), + 'fingerprint' => VerificationReportFingerprint::forChecks($normalizedChecks), + 'previous_report_id' => null, 'summary' => [ 'overall' => self::deriveOverall($normalizedChecks, $counts), 'counts' => $counts, @@ -98,6 +102,8 @@ private static function buildFallbackReport(string $flow): array 'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION, 'flow' => $flow !== '' ? $flow : 'unknown', 'generated_at' => now()->toISOString(), + 'fingerprint' => VerificationReportFingerprint::forChecks([]), + 'previous_report_id' => null, 'summary' => [ 'overall' => VerificationReportOverall::NeedsAttention->value, 'counts' => [ @@ -161,14 +167,12 @@ private static function normalizeCheckStatus(mixed $status): string private static function normalizeCheckSeverity(mixed $severity): string { if (! is_string($severity)) { - return VerificationCheckSeverity::Info->value; + return ''; } $severity = strtolower(trim($severity)); - return in_array($severity, VerificationCheckSeverity::values(), true) - ? $severity - : VerificationCheckSeverity::Info->value; + return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : ''; } private static function normalizeReasonCode(mixed $reasonCode): string diff --git a/database/factories/VerificationCheckAcknowledgementFactory.php b/database/factories/VerificationCheckAcknowledgementFactory.php new file mode 100644 index 0000000..ff37537 --- /dev/null +++ b/database/factories/VerificationCheckAcknowledgementFactory.php @@ -0,0 +1,37 @@ + + */ +class VerificationCheckAcknowledgementFactory extends Factory +{ + protected $model = VerificationCheckAcknowledgement::class; + + public function definition(): array + { + return [ + 'operation_run_id' => function (): int { + return (int) OperationRun::factory()->create()->getKey(); + }, + 'tenant_id' => function (array $attributes): int { + return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('tenant_id'); + }, + 'workspace_id' => function (array $attributes): int { + return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('workspace_id'); + }, + 'check_key' => 'provider_connection.token_acquisition', + 'ack_reason' => fake()->sentence(6), + 'expires_at' => null, + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => User::factory(), + ]; + } +} + diff --git a/database/migrations/2026_02_05_000001_create_verification_check_acknowledgements_table.php b/database/migrations/2026_02_05_000001_create_verification_check_acknowledgements_table.php new file mode 100644 index 0000000..de4091d --- /dev/null +++ b/database/migrations/2026_02_05_000001_create_verification_check_acknowledgements_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('operation_run_id')->constrained('operation_runs')->cascadeOnDelete(); + + $table->string('check_key'); + $table->string('ack_reason', 160); + $table->timestampTz('expires_at')->nullable(); + $table->timestampTz('acknowledged_at'); + $table->foreignId('acknowledged_by_user_id')->constrained('users'); + + $table->timestamps(); + + $table->unique(['operation_run_id', 'check_key']); + + $table->index(['tenant_id', 'workspace_id', 'operation_run_id']); + $table->index(['operation_run_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('verification_check_acknowledgements'); + } +}; + diff --git a/phpunit.xml b/phpunit.xml index 75c4ea3..fb74b6b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,6 +20,7 @@ + diff --git a/resources/views/filament/components/verification-report-viewer.blade.php b/resources/views/filament/components/verification-report-viewer.blade.php index b640b52..8411f1a 100644 --- a/resources/views/filament/components/verification-report-viewer.blade.php +++ b/resources/views/filament/components/verification-report-viewer.blade.php @@ -2,14 +2,95 @@ $report = isset($getState) ? $getState() : ($report ?? null); $report = is_array($report) ? $report : null; + $run = $run ?? null; + $run = is_array($run) ? $run : null; + + $fingerprint = $fingerprint ?? null; + $fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null; + + $changeIndicator = $changeIndicator ?? null; + $changeIndicator = is_array($changeIndicator) ? $changeIndicator : null; + + $previousRunUrl = $previousRunUrl ?? null; + $previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null; + + $acknowledgements = $acknowledgements ?? []; + $acknowledgements = is_array($acknowledgements) ? $acknowledgements : []; + $summary = $report['summary'] ?? null; $summary = is_array($summary) ? $summary : null; - $counts = $summary['counts'] ?? null; - $counts = is_array($counts) ? $counts : []; + $counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : []; $checks = $report['checks'] ?? null; $checks = is_array($checks) ? $checks : []; + + $ackByKey = []; + + foreach ($acknowledgements as $checkKey => $ack) { + if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) { + continue; + } + + $ackByKey[$checkKey] = $ack; + } + + $blockers = []; + $failures = []; + $warnings = []; + $acknowledgedIssues = []; + $passed = []; + + foreach ($checks as $check) { + $check = is_array($check) ? $check : []; + + $key = $check['key'] ?? null; + $key = is_string($key) ? trim($key) : ''; + + if ($key === '') { + continue; + } + + $statusValue = $check['status'] ?? null; + $statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : ''; + + $blocking = $check['blocking'] ?? false; + $blocking = is_bool($blocking) ? $blocking : false; + + if (array_key_exists($key, $ackByKey)) { + $acknowledgedIssues[] = $check; + continue; + } + + if ($statusValue === 'pass') { + $passed[] = $check; + continue; + } + + if ($statusValue === 'fail' && $blocking) { + $blockers[] = $check; + continue; + } + + if ($statusValue === 'fail') { + $failures[] = $check; + continue; + } + + if ($statusValue === 'warn') { + $warnings[] = $check; + } + } + + $sortChecks = static function (array $a, array $b): int { + return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? '')); + }; + + usort($blockers, $sortChecks); + usort($failures, $sortChecks); + usort($warnings, $sortChecks); + usort($acknowledgedIssues, $sortChecks); + usort($passed, $sortChecks); @endphp
@@ -21,6 +102,9 @@
This run doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
+
+ Read-only: this view uses stored data and makes no external calls. +
@else @php @@ -30,149 +114,377 @@ ); @endphp -
- - {{ $overallSpec->label }} - +
+
+ + {{ $overallSpec->label }} + - - {{ (int) ($counts['total'] ?? 0) }} total - - - {{ (int) ($counts['pass'] ?? 0) }} pass - - - {{ (int) ($counts['fail'] ?? 0) }} fail - - - {{ (int) ($counts['warn'] ?? 0) }} warn - - - {{ (int) ($counts['skip'] ?? 0) }} skip - - - {{ (int) ($counts['running'] ?? 0) }} running - -
+ + {{ (int) ($counts['total'] ?? 0) }} total + + + {{ (int) ($counts['pass'] ?? 0) }} pass + + + {{ (int) ($counts['fail'] ?? 0) }} fail + + + {{ (int) ($counts['warn'] ?? 0) }} warn + + + {{ (int) ($counts['skip'] ?? 0) }} skip + + + {{ (int) ($counts['running'] ?? 0) }} running + - @if ($checks === []) -
- No checks found in this report. Start verification again to generate a fresh report. -
- @else -
- @foreach ($checks as $check) + @if ($changeIndicator !== null) @php - $check = is_array($check) ? $check : []; - - $title = $check['title'] ?? 'Check'; - $title = is_string($title) && trim($title) !== '' ? $title : 'Check'; - - $message = $check['message'] ?? null; - $message = is_string($message) && trim($message) !== '' ? $message : null; - - $statusSpec = \App\Support\Badges\BadgeRenderer::spec( - \App\Support\Badges\BadgeDomain::VerificationCheckStatus, - $check['status'] ?? null, - ); - - $severitySpec = \App\Support\Badges\BadgeRenderer::spec( - \App\Support\Badges\BadgeDomain::VerificationCheckSeverity, - $check['severity'] ?? null, - ); - - $evidence = $check['evidence'] ?? []; - $evidence = is_array($evidence) ? $evidence : []; - - $nextSteps = $check['next_steps'] ?? []; - $nextSteps = is_array($nextSteps) ? $nextSteps : []; + $state = $changeIndicator['state'] ?? null; + $state = is_string($state) ? $state : null; @endphp -
- -
-
+ @if ($state === 'no_changes') + + No changes since previous verification + + @elseif ($state === 'changed') + + Changed since previous verification + + @endif + @endif +
+ +
+ Read-only: this view uses stored data and makes no external calls. +
+
+ +
+ + + Issues + + + Passed + + + Technical details + + + +
+ @if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === []) +
+ No issues found in this report. +
+ @else +
+ @php + $issueGroups = [ + ['label' => 'Blockers', 'checks' => $blockers], + ['label' => 'Failures', 'checks' => $failures], + ['label' => 'Warnings', 'checks' => $warnings], + ]; + @endphp + + @foreach ($issueGroups as $group) + @php + $label = $group['label']; + $groupChecks = $group['checks']; + @endphp + + @if ($groupChecks !== []) +
+
+ {{ $label }} +
+ +
+ @foreach ($groupChecks as $check) + @php + $check = is_array($check) ? $check : []; + + $title = $check['title'] ?? 'Check'; + $title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check'; + + $message = $check['message'] ?? null; + $message = is_string($message) && trim($message) !== '' ? trim($message) : null; + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckStatus, + $check['status'] ?? null, + ); + + $severitySpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckSeverity, + $check['severity'] ?? null, + ); + + $nextSteps = $check['next_steps'] ?? []; + $nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : []; + + $blocking = $check['blocking'] ?? false; + $blocking = is_bool($blocking) ? $blocking : false; + @endphp + +
+
+
+
+ {{ $title }} +
+ @if ($message) +
+ {{ $message }} +
+ @endif +
+ +
+ @if ($blocking) + + Blocker + + @endif + + + {{ $severitySpec->label }} + + + {{ $statusSpec->label }} + +
+
+ + @if ($nextSteps !== []) +
+
+ Next steps +
+
    + @foreach ($nextSteps as $step) + @php + $step = is_array($step) ? $step : []; + $label = $step['label'] ?? null; + $url = $step['url'] ?? null; + $isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')); + @endphp + + @if (is_string($label) && $label !== '' && is_string($url) && $url !== '') +
  • + + {{ $label }} + +
  • + @endif + @endforeach +
+
+ @endif +
+ @endforeach +
+
+ @endif + @endforeach + + @if ($acknowledgedIssues !== []) +
+ + Acknowledged issues + + +
+ @foreach ($acknowledgedIssues as $check) + @php + $check = is_array($check) ? $check : []; + $checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : ''; + + $title = $check['title'] ?? 'Check'; + $title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check'; + + $message = $check['message'] ?? null; + $message = is_string($message) && trim($message) !== '' ? trim($message) : null; + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckStatus, + $check['status'] ?? null, + ); + + $severitySpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckSeverity, + $check['severity'] ?? null, + ); + + $ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null; + $ack = is_array($ack) ? $ack : null; + + $ackReason = $ack['ack_reason'] ?? null; + $ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null; + + $ackAt = $ack['acknowledged_at'] ?? null; + $ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null; + + $ackBy = $ack['acknowledged_by'] ?? null; + $ackBy = is_array($ackBy) ? $ackBy : null; + + $ackByName = $ackBy['name'] ?? null; + $ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null; + + $expiresAt = $ack['expires_at'] ?? null; + $expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null; + @endphp + +
+
+
+
+ {{ $title }} +
+ @if ($message) +
+ {{ $message }} +
+ @endif +
+ +
+ + {{ $severitySpec->label }} + + + {{ $statusSpec->label }} + +
+
+ + @if ($ackReason || $ackAt || $ackByName || $expiresAt) +
+ @if ($ackReason) +
+ Reason: {{ $ackReason }} +
+ @endif + @if ($ackByName || $ackAt) +
+ Acknowledged: + @if ($ackByName) + {{ $ackByName }} + @endif + @if ($ackAt) + ({{ $ackAt }}) + @endif +
+ @endif + @if ($expiresAt) +
+ Expires: + {{ $expiresAt }} +
+ @endif +
+ @endif +
+ @endforeach +
+
+ @endif +
+ @endif +
+ +
+ @if ($passed === []) +
+ No passing checks recorded. +
+ @else +
+ @foreach ($passed as $check) + @php + $check = is_array($check) ? $check : []; + + $title = $check['title'] ?? 'Check'; + $title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check'; + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckStatus, + $check['status'] ?? null, + ); + @endphp + +
+
{{ $title }}
- @if ($message) -
- {{ $message }} -
- @endif -
- -
- - {{ $severitySpec->label }} - {{ $statusSpec->label }}
-
- - @if ($evidence !== [] || $nextSteps !== []) -
- @if ($evidence !== []) -
-
- Evidence -
-
    - @foreach ($evidence as $pointer) - @php - $pointer = is_array($pointer) ? $pointer : []; - $kind = $pointer['kind'] ?? null; - $value = $pointer['value'] ?? null; - @endphp - - @if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value))) -
  • - {{ $kind }}: - {{ is_int($value) ? $value : $value }} -
  • - @endif - @endforeach -
-
- @endif - - @if ($nextSteps !== []) -
-
- Next steps -
-
    - @foreach ($nextSteps as $step) - @php - $step = is_array($step) ? $step : []; - $label = $step['label'] ?? null; - $url = $step['url'] ?? null; - $isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')); - @endphp - - @if (is_string($label) && $label !== '' && is_string($url) && $url !== '') -
  • - - {{ $label }} - -
  • - @endif - @endforeach -
-
- @endif -
- @endif -
- @endforeach + @endforeach +
+ @endif
- @endif + +
+
+
+
+ Identifiers +
+
+ @if ($run !== null) +
+ Run ID: + {{ (int) ($run['id'] ?? 0) }} +
+
+ Flow: + {{ (string) ($run['type'] ?? '') }} +
+ @endif + @if ($fingerprint) +
+ Fingerprint: + {{ $fingerprint }} +
+ @endif +
+
+ + @if ($previousRunUrl !== null) + + @endif +
+
+
@endif diff --git a/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php b/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php index 189dfc4..34fdb2a 100644 --- a/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php +++ b/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php @@ -7,6 +7,23 @@ $runUrl = $runUrl ?? null; $runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null; + $report = $report ?? null; + $report = is_array($report) ? $report : null; + + $fingerprint = $fingerprint ?? null; + $fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null; + + $changeIndicator = $changeIndicator ?? null; + $changeIndicator = is_array($changeIndicator) ? $changeIndicator : null; + + $previousRunUrl = $previousRunUrl ?? null; + $previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null; + + $canAcknowledge = (bool) ($canAcknowledge ?? false); + + $acknowledgements = $acknowledgements ?? []; + $acknowledgements = is_array($acknowledgements) ? $acknowledgements : []; + $status = $run['status'] ?? null; $status = is_string($status) ? $status : null; @@ -31,6 +48,87 @@ $completedAtLabel = $completedAt; } } + + $summary = $report['summary'] ?? null; + $summary = is_array($summary) ? $summary : null; + + $counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : []; + + $checks = $report['checks'] ?? null; + $checks = is_array($checks) ? $checks : []; + + $ackByKey = []; + + foreach ($acknowledgements as $checkKey => $ack) { + if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) { + continue; + } + + $ackByKey[$checkKey] = $ack; + } + + $blockers = []; + $failures = []; + $warnings = []; + $acknowledgedIssues = []; + $passed = []; + + foreach ($checks as $check) { + $check = is_array($check) ? $check : []; + + $key = $check['key'] ?? null; + $key = is_string($key) ? trim($key) : ''; + + if ($key === '') { + continue; + } + + $statusValue = $check['status'] ?? null; + $statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : ''; + + $blocking = $check['blocking'] ?? false; + $blocking = is_bool($blocking) ? $blocking : false; + + if (array_key_exists($key, $ackByKey)) { + $acknowledgedIssues[] = $check; + continue; + } + + if ($statusValue === 'pass') { + $passed[] = $check; + continue; + } + + if ($statusValue === 'fail' && $blocking) { + $blockers[] = $check; + continue; + } + + if ($statusValue === 'fail') { + $failures[] = $check; + continue; + } + + if ($statusValue === 'warn') { + $warnings[] = $check; + } + } + + $sortChecks = static function (array $a, array $b): int { + return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? '')); + }; + + usort($blockers, $sortChecks); + usort($failures, $sortChecks); + usort($warnings, $sortChecks); + usort($acknowledgedIssues, $sortChecks); + usort($passed, $sortChecks); + + $ackAction = null; + + if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) { + $ackAction = $this->acknowledgeVerificationCheckAction(); + } @endphp @@ -47,88 +145,450 @@
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
- @elseif ($outcome === 'succeeded') -
- All verification checks passed. -
- @elseif ($failures === []) -
- Report unavailable. The run completed, but no failure details were recorded. -
@else -
-
- Findings -
- -
    - @foreach ($failures as $failure) - @php - $reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null; - $message = is_array($failure) ? ($failure['message'] ?? null) : null; - - $reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null; - $message = is_string($message) && $message !== '' ? $message : null; - @endphp - - @if ($reasonCode !== null || $message !== null) -
  • - @if ($reasonCode !== null) -
    - {{ $reasonCode }} -
    - @endif - @if ($message !== null) -
    - {{ $message }} -
    - @endif -
  • - @endif - @endforeach -
-
- @endif - - @if ($targetScope !== []) -
-
- Target scope -
-
+
+
@php - $entraTenantId = $targetScope['entra_tenant_id'] ?? null; - $entraTenantName = $targetScope['entra_tenant_name'] ?? null; - - $entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null; - $entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null; + $overallSpec = $summary === null + ? null + : \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationReportOverall, + $summary['overall'] ?? null, + ); @endphp - @if ($entraTenantName !== null) -
- Entra tenant: - {{ $entraTenantName }} -
- @endif +
+ @if ($overallSpec) + + {{ $overallSpec->label }} + + @endif - @if ($entraTenantId !== null) -
- Entra tenant ID: - {{ $entraTenantId }} -
- @endif + + {{ (int) ($counts['total'] ?? 0) }} total + + + {{ (int) ($counts['pass'] ?? 0) }} pass + + + {{ (int) ($counts['fail'] ?? 0) }} fail + + + {{ (int) ($counts['warn'] ?? 0) }} warn + + + {{ (int) ($counts['skip'] ?? 0) }} skip + + + {{ (int) ($counts['running'] ?? 0) }} running + + + @if ($changeIndicator !== null) + @php + $state = $changeIndicator['state'] ?? null; + $state = is_string($state) ? $state : null; + @endphp + + @if ($state === 'no_changes') + + No changes since previous verification + + @elseif ($state === 'changed') + + Changed since previous verification + + @endif + @endif +
+ +
+ Read-only: this view uses stored data and makes no external calls. +
-
- @endif - @if ($runUrl !== null) -
- - Open run details - + @if ($report === null || $summary === null) +
+
+ Verification report unavailable +
+
+ This run doesn’t have a report yet. If it already completed, start verification again. +
+
+ @else +
+ + + Issues + + + Passed + + + Technical details + + + +
+ @if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === []) +
+ No issues found in this report. +
+ @else +
+ @php + $issueGroups = [ + ['label' => 'Blockers', 'checks' => $blockers], + ['label' => 'Failures', 'checks' => $failures], + ['label' => 'Warnings', 'checks' => $warnings], + ]; + @endphp + + @foreach ($issueGroups as $group) + @php + $label = $group['label']; + $groupChecks = $group['checks']; + @endphp + + @if ($groupChecks !== []) +
+
+ {{ $label }} +
+ +
+ @foreach ($groupChecks as $check) + @php + $check = is_array($check) ? $check : []; + $checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : ''; + + $title = $check['title'] ?? 'Check'; + $title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check'; + + $message = $check['message'] ?? null; + $message = is_string($message) && trim($message) !== '' ? trim($message) : null; + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckStatus, + $check['status'] ?? null, + ); + + $severitySpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckSeverity, + $check['severity'] ?? null, + ); + + $nextSteps = $check['next_steps'] ?? []; + $nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : []; + + $blocking = $check['blocking'] ?? false; + $blocking = is_bool($blocking) ? $blocking : false; + @endphp + +
+
+
+
+ {{ $title }} +
+ @if ($message) +
+ {{ $message }} +
+ @endif +
+ +
+ @if ($blocking) + + Blocker + + @endif + + + {{ $severitySpec->label }} + + + {{ $statusSpec->label }} + + + @if ($ackAction !== null && $canAcknowledge && $checkKey !== '') + {{ ($ackAction)(['check_key' => $checkKey]) }} + @endif +
+
+ + @if ($nextSteps !== []) +
+
+ Next steps +
+
    + @foreach ($nextSteps as $step) + @php + $step = is_array($step) ? $step : []; + $label = $step['label'] ?? null; + $url = $step['url'] ?? null; + $isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')); + @endphp + + @if (is_string($label) && $label !== '' && is_string($url) && $url !== '') +
  • + + {{ $label }} + +
  • + @endif + @endforeach +
+
+ @endif +
+ @endforeach +
+
+ @endif + @endforeach + + @if ($acknowledgedIssues !== []) +
+ + Acknowledged issues + + +
+ @foreach ($acknowledgedIssues as $check) + @php + $check = is_array($check) ? $check : []; + $checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : ''; + + $title = $check['title'] ?? 'Check'; + $title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check'; + + $message = $check['message'] ?? null; + $message = is_string($message) && trim($message) !== '' ? trim($message) : null; + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckStatus, + $check['status'] ?? null, + ); + + $severitySpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckSeverity, + $check['severity'] ?? null, + ); + + $ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null; + $ack = is_array($ack) ? $ack : null; + + $ackReason = $ack['ack_reason'] ?? null; + $ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null; + + $ackAt = $ack['acknowledged_at'] ?? null; + $ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null; + + $ackBy = $ack['acknowledged_by'] ?? null; + $ackBy = is_array($ackBy) ? $ackBy : null; + + $ackByName = $ackBy['name'] ?? null; + $ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null; + + $expiresAt = $ack['expires_at'] ?? null; + $expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null; + @endphp + +
+
+
+
+ {{ $title }} +
+ @if ($message) +
+ {{ $message }} +
+ @endif +
+ +
+ + {{ $severitySpec->label }} + + + {{ $statusSpec->label }} + +
+
+ + @if ($ackReason || $ackAt || $ackByName || $expiresAt) +
+ @if ($ackReason) +
+ Reason: {{ $ackReason }} +
+ @endif + @if ($ackByName || $ackAt) +
+ Acknowledged: + @if ($ackByName) + {{ $ackByName }} + @endif + @if ($ackAt) + ({{ $ackAt }}) + @endif +
+ @endif + @if ($expiresAt) +
+ Expires: + {{ $expiresAt }} +
+ @endif +
+ @endif +
+ @endforeach +
+
+ @endif +
+ @endif +
+ +
+ @if ($passed === []) +
+ No passing checks recorded. +
+ @else +
+ @foreach ($passed as $check) + @php + $check = is_array($check) ? $check : []; + + $title = $check['title'] ?? 'Check'; + $title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check'; + + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::VerificationCheckStatus, + $check['status'] ?? null, + ); + @endphp + +
+
+ {{ $title }} +
+ + {{ $statusSpec->label }} + +
+ @endforeach +
+ @endif +
+ +
+
+
+
+ Identifiers +
+
+
+ Run ID: + {{ (int) ($run['id'] ?? 0) }} +
+
+ Flow: + {{ (string) ($run['type'] ?? '') }} +
+ @if ($fingerprint) +
+ Fingerprint: + {{ $fingerprint }} +
+ @endif +
+
+ + @if ($previousRunUrl !== null) + + @endif + + @if ($runUrl !== null) + + @endif + + @if ($targetScope !== []) +
+
+ Target scope +
+
+ @php + $entraTenantId = $targetScope['entra_tenant_id'] ?? null; + $entraTenantName = $targetScope['entra_tenant_name'] ?? null; + + $entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null; + $entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null; + @endphp + + @if ($entraTenantName !== null) +
+ Entra tenant: + {{ $entraTenantName }} +
+ @endif + + @if ($entraTenantId !== null) +
+ Entra tenant ID: + {{ $entraTenantId }} +
+ @endif +
+
+ @endif +
+
+
+ @endif
@endif diff --git a/specs/075-verification-v1-5/checklists/requirements.md b/specs/075-verification-v1-5/checklists/requirements.md new file mode 100644 index 0000000..6714a72 --- /dev/null +++ b/specs/075-verification-v1-5/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Verification Checklist Framework V1.5 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-05 +**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 + +## Notes + +- Reviewed against template used in [specs/074-verification-checklist/spec.md](../../074-verification-checklist/spec.md). No open clarifications remain. diff --git a/specs/075-verification-v1-5/contracts/acknowledge-check.request.schema.json b/specs/075-verification-v1-5/contracts/acknowledge-check.request.schema.json new file mode 100644 index 0000000..646c407 --- /dev/null +++ b/specs/075-verification-v1-5/contracts/acknowledge-check.request.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tenantpilot.local/contracts/acknowledge-check.request.schema.json", + "title": "AcknowledgeVerificationCheckRequest", + "type": "object", + "additionalProperties": false, + "required": [ + "report_id", + "check_key", + "ack_reason" + ], + "properties": { + "report_id": { + "type": ["string", "integer"] + }, + "check_key": { + "type": "string", + "minLength": 1 + }, + "ack_reason": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "expires_at": { + "description": "Optional informational expiry timestamp.", + "type": ["string", "null"], + "format": "date-time" + } + } +} diff --git a/specs/075-verification-v1-5/contracts/verification-check-acknowledgement.schema.json b/specs/075-verification-v1-5/contracts/verification-check-acknowledgement.schema.json new file mode 100644 index 0000000..3efe117 --- /dev/null +++ b/specs/075-verification-v1-5/contracts/verification-check-acknowledgement.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tenantpilot.local/contracts/verification-check-acknowledgement.schema.json", + "title": "VerificationCheckAcknowledgement", + "type": "object", + "additionalProperties": false, + "required": [ + "report_id", + "check_key", + "ack_reason", + "acknowledged_at", + "acknowledged_by" + ], + "properties": { + "report_id": { + "description": "OperationRun id that contains the report.", + "type": ["string", "integer"] + }, + "check_key": { + "type": "string", + "minLength": 1 + }, + "ack_reason": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "expires_at": { + "description": "Informational only in v1.5.", + "type": ["string", "null"], + "format": "date-time" + }, + "acknowledged_at": { + "type": "string", + "format": "date-time" + }, + "acknowledged_by": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": ["string", "integer"] }, + "name": { "type": ["string", "null"] }, + "email": { "type": ["string", "null"] } + } + } + } +} diff --git a/specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json b/specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json new file mode 100644 index 0000000..098dda5 --- /dev/null +++ b/specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tenantpilot.local/contracts/verification-report.v1_5.schema.json", + "title": "VerificationReportV1_5", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "flow", + "generated_at", + "fingerprint", + "previous_report_id", + "summary", + "checks" + ], + "properties": { + "report_id": { + "description": "Canonical report identifier. In v1.5 this is the OperationRun id.", + "type": ["string", "integer"] + }, + "schema_version": { + "type": "string", + "description": "Version of the verification report schema (SemVer, major 1).", + "pattern": "^1\\.[0-9]+\\.[0-9]+$" + }, + "flow": { + "type": "string", + "description": "Verification flow identifier (v1 aligns with OperationRun.type)." + }, + "previous_report_id": { + "description": "Previous report id for the same identity (nullable).", + "type": ["string", "integer", "null"] + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "identity": { + "type": "object", + "description": "Scope identifiers for what is being verified.", + "additionalProperties": true + }, + "fingerprint": { + "description": "Deterministic SHA-256 hash (lowercase hex) of normalized check outcomes.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["overall", "counts"], + "properties": { + "overall": { + "type": "string", + "enum": ["ready", "needs_attention", "blocked", "running"], + "description": "Overall state derived from check results." + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["total", "pass", "fail", "warn", "skip", "running"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "pass": { "type": "integer", "minimum": 0 }, + "fail": { "type": "integer", "minimum": 0 }, + "warn": { "type": "integer", "minimum": 0 }, + "skip": { "type": "integer", "minimum": 0 }, + "running": { "type": "integer", "minimum": 0 } + } + } + } + }, + "checks": { + "type": "array", + "minItems": 0, + "items": { + "$ref": "#/$defs/CheckResult" + } + } + }, + "$defs": { + "CheckResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "key", + "title", + "status", + "severity", + "blocking", + "reason_code", + "message", + "evidence", + "next_steps" + ], + "properties": { + "key": { "type": "string" }, + "title": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pass", "fail", "warn", "skip", "running"] + }, + "severity": { + "description": "Must be included for fingerprint determinism; may be empty string.", + "type": "string", + "enum": ["", "info", "low", "medium", "high", "critical"] + }, + "blocking": { "type": "boolean" }, + "reason_code": { "type": "string" }, + "message": { "type": "string" }, + "evidence": { + "type": "array", + "items": { "$ref": "#/$defs/EvidencePointer" } + }, + "next_steps": { + "type": "array", + "description": "Navigation-only CTAs (links) in v1.", + "items": { "$ref": "#/$defs/NextStep" } + } + } + }, + "EvidencePointer": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "value"], + "properties": { + "kind": { "type": "string" }, + "value": { + "description": "Safe pointer value (ID/masked string/hash).", + "oneOf": [ + { "type": "integer" }, + { "type": "string" } + ] + } + } + }, + "NextStep": { + "type": "object", + "additionalProperties": false, + "required": ["label", "url"], + "properties": { + "label": { "type": "string" }, + "url": { "type": "string" } + } + } + } +} diff --git a/specs/075-verification-v1-5/data-model.md b/specs/075-verification-v1-5/data-model.md new file mode 100644 index 0000000..b53cc40 --- /dev/null +++ b/specs/075-verification-v1-5/data-model.md @@ -0,0 +1,114 @@ +# Data Model: Verification Checklist Framework V1.5 (075) + +**Date**: 2026-02-05 +**Phase**: Phase 1 (Design) +**Status**: Draft (design-complete for implementation planning) + +--- + +## Existing Entity (reference) + +### OperationRun (existing) + +**Purpose**: Canonical operational record. Verification reports are stored in `operation_runs.context.verification_report` (JSONB). + +**Key fields (relevant)**: +- `id` +- `tenant_id` +- `workspace_id` +- `type` (verification flow identifier) +- `run_identity_hash` (identity hash used for active dedupe + identity matching) +- `status` +- `context` (JSONB) + +**Verification report storage**: +- `context.verification_report` (JSON object) + +--- + +## New Persistent Entity + +### VerificationCheckAcknowledgement (new table) + +**Table name**: `verification_check_acknowledgements` + +**Purpose**: First-class governance record that a failing/warning check is acknowledged for a specific report (report == operation run). + +**Fields**: +- `id` (primary key) +- `tenant_id` (FK or scalar; used for tenant-scoped filtering and isolation checks) +- `workspace_id` (FK or scalar) +- `operation_run_id` (FK to `operation_runs.id`) — the “report” +- `check_key` (string) +- `ack_reason` (string, max 160) +- `expires_at` (timestamp, nullable) — informational only in v1.5 +- `acknowledged_at` (timestamp) +- `acknowledged_by_user_id` (FK to `users.id`) +- `created_at`, `updated_at` + +**Uniqueness constraint (required)**: +- unique `(operation_run_id, check_key)` + +**Indexes (recommended)**: +- `(tenant_id, workspace_id, operation_run_id)` +- `(operation_run_id)` + +**Validation rules**: +- `ack_reason`: required, string, length ≤ 160 +- `expires_at`: optional, must be a valid timestamp, should be >= acknowledged_at (implementation may enforce) + +**State transitions**: +- Immutable per report/check in v1.5: create once; no update/delete/unack flows. + +--- + +## Contracted Document (stored in JSON) + +### VerificationReport (JSON in `OperationRun.context.verification_report`) + +**Purpose**: Structured, versioned report of verification results used by the DB-only viewer. + +**Identity**: +- `report_id`: `operation_runs.id` +- `previous_report_id`: previous run id for same identity (nullable) + +**New v1.5 fields**: +- `fingerprint` (string; lowercase hex; SHA-256) +- `previous_report_id` (nullable integer/uuid depending on `OperationRun` PK type) + +**Existing core fields (from 074, reference)**: +- `schema_version` (SemVer string; major `1`) +- `flow` (verification flow identifier; aligns with `OperationRun.type`) +- `generated_at` (timestamp) +- `summary` (counts + overall outcome) +- `checks[]` (flat array) including: + - `key` + - `title` + - `status` (`pass|fail|warn|skip|running`) + - `severity` (`info|low|medium|high|critical` or empty string) + - `blocking` (boolean) + - `reason_code` (string) + - safe evidence pointers + - `next_steps[]` (navigation-only links) + +**Fingerprint normalization input** (strict): +- Flatten all checks across `checks[]`. +- Sort by `check.key`. +- Contribute the stable tuple string: + - `key|status|blocking|reason_code|severity` + - `severity` must always be present (missing normalized to empty string). + +--- + +## Derived/Computed View Data (not persisted) + +### Change indicator + +Computed in the viewer by comparing: +- current `verification_report.fingerprint` +- previous `verification_report.fingerprint` + +States: +- no previous report → no indicator +- fingerprints match → “No changes since previous verification” +- fingerprints differ → “Changed since previous verification” diff --git a/specs/075-verification-v1-5/plan.md b/specs/075-verification-v1-5/plan.md new file mode 100644 index 0000000..fe2954f --- /dev/null +++ b/specs/075-verification-v1-5/plan.md @@ -0,0 +1,150 @@ +# Implementation Plan: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete) + +**Branch**: `075-verification-v1_5` | **Date**: 2026-02-05 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/075-verification-v1-5/spec.md` + +**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps. + +## Summary + +- Extend the existing 074 verification report system with deterministic **fingerprints** and a **previous report** link so the viewer can show “Changed / No changes”. +- Introduce per-check **acknowledgements** as first-class records (unique per report + check) with explicit confirmation and audit logging, without changing outcomes (“no greenwashing”). +- Update the Verify step UX to be operator-ready: issues-first tabs, centralized badge semantics (BADGE-001), and exactly one primary CTA depending on state. + +## Technical Context + + + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 (Filament v5 requires Livewire v4.0+) +**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`) + a new acknowledgement table +**Testing**: Pest (PHPUnit) +**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy) +**Project Type**: web +**Performance Goals**: DB-only viewer renders quickly from stored JSON; fingerprint computation is linear in number of checks (typical report ≤ 50 checks) +**Constraints**: +- Viewer + Verify step are DB-only at render time (no outbound HTTP / Graph / job dispatch). +- All mutations require server-side authorization (RBAC-UX) and explicit confirmation. +- Status-like UI must use centralized badge semantics (BADGE-001). +**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding and provider workflows + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first, snapshots-second: PASS (report/ack UX; no inventory semantics changed). +- Read/write separation: PASS (viewer remains read-only; acknowledgements are explicit mutations with confirmation + audit + tests). +- Graph contract path: PASS (viewer is DB-only; no new Graph calls added by this feature). +- Deterministic capabilities: PASS (capabilities remain centrally registered; no raw strings). +- RBAC-UX: PASS (non-member access is 404; member missing capability is 403; server-side enforcement required). +- Run observability: PASS (verification remains an `OperationRun`; dedupe while active is unchanged). +- Data minimization: PASS (no secrets/tokens; audit payload excludes `ack_reason`). +- Badge semantics (BADGE-001): PASS (no new status values; existing verification badge domains remain canonical). + +## Project Structure + +### Documentation (this feature) + +```text +specs/075-verification-v1-5/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + + +```text +app/ +├── Filament/ +├── Jobs/ +├── Models/ +├── Policies/ +├── Services/ +└── Support/ + +database/ +└── migrations/ + +resources/ +routes/ +tests/ +``` + +**Structure Decision**: Single Laravel web app with Filament v5 panel. This feature extends verification report writer/viewer, adds an acknowledgement persistence model + migration, and refactors the Verify step UI in Filament. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | + +## Phase 0 — Research (output: `research.md`) + +See: [research.md](./research.md) + +Goals covered: +- Confirm canonical storage approach for report metadata (keep report in `operation_runs.context`). +- Define deterministic fingerprint algorithm and previous report resolution rules. +- Define acknowledgement persistence strategy and capability naming reconciliation. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- [data-model.md](./data-model.md) +- [contracts/](./contracts/) +- [quickstart.md](./quickstart.md) + +Design focus: +- Report metadata: add `fingerprint` and `previous_report_id` inside the report JSON. +- Previous report resolution: match identity exactly (type/flow + tenant + workspace + provider connection), with `NULL` connection matching only `NULL`. +- Acknowledgements: first-class DB table keyed by `(operation_run_id, check_key)`; immutable in v1.5. +- Filament UX: issues-first tabs and “one primary CTA” rule; acknowledgements via `Action::make(...)->action(...)` + `->requiresConfirmation()`. + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +### Data +- Migration: create `verification_check_acknowledgements` table with unique `(operation_run_id, check_key)`. +- Model: `VerificationCheckAcknowledgement` with tenant/workspace scoping. + +### Report writer / viewer +- Extend the report writer to compute and store `fingerprint` and `previous_report_id` (report_id is the run id). +- Extend the DB-only viewer to load previous report (when present) and compute the “changed/no-change” indicator. +- Ensure the viewer consumes acknowledgements (DB lookup) and groups “Acknowledged issues” separately. + +### Authorization + audit +- Capability: add `tenant_verification.acknowledge` to the canonical capability registry and map to roles. +- Server-side auth: non-members 404; members without capability 403 for acknowledgement. +- Audit: add a new stable action ID (e.g. `verification.check_acknowledged`) with minimal metadata and no `ack_reason`. + +### Filament UX +- Verify step: implement the issues-first layout and strict “exactly one primary CTA” rule. +- Actions: acknowledgement requires confirmation; navigation-only links remain links-only. +- BADGE-001: continue to use centralized badge domains for statuses and summary. + +### Tests (Pest) +- Fingerprint determinism: same normalized inputs → same hash; severity-only differences → different hash. +- Previous report linking: identity match includes provider connection (`NULL` only matches `NULL`). +- RBAC-UX: non-member gets 404; member without capability gets 403 on acknowledgement. +- Audit: acknowledgement emits correct action id + minimal metadata (assert `ack_reason` absent). +- Viewer DB-only: no outbound HTTP during render/hydration. + +## Constitution Check (Post-Design) + +Re-check result: PASS. Design keeps report viewing DB-only, introduces a single tenant-scoped mutation with confirmation + audit, preserves RBAC-UX semantics, and maintains BADGE-001 centralized badge rendering. diff --git a/specs/075-verification-v1-5/quickstart.md b/specs/075-verification-v1-5/quickstart.md new file mode 100644 index 0000000..9c4eea6 --- /dev/null +++ b/specs/075-verification-v1-5/quickstart.md @@ -0,0 +1,47 @@ +# Quickstart: Verification Checklist Framework V1.5 (075) + +This quickstart is for developers implementing and validating Spec 075. + +## Prerequisites + +- Docker + Sail +- A seeded workspace + tenant and a user that can access the tenant plane (`/admin/t/{tenant}`) + +## Local setup + +- Start containers: `vendor/bin/sail up -d` +- Install deps (if needed): `vendor/bin/sail composer install` +- Run migrations: `vendor/bin/sail artisan migrate` + +## Run verification (expected UX) + +After implementation, the Verify surface should behave like: + +1. Navigate to the tenant-scoped Verify step (onboarding or equivalent). +2. If no active run exists, the single primary CTA is **Start verification**. +3. If a run is active, the single primary CTA is **Refresh results**. +4. Results default to the **Issues** tab with blockers/failures/warnings ordered first. + +## Acknowledge a check (expected UX) + +After implementation: + +1. On a `fail` or `warn` check card, click **Acknowledge**. +2. Confirmation modal appears (required). +3. Submit a short reason (≤ 160 chars) and optional expiry (informational only). +4. The acknowledgement displays (who/when/reason) and the issue moves into the “Acknowledged issues” group. + +## Authorization expectations + +- Non-members attempting to access tenant-scoped verification pages: deny-as-not-found (404). +- Tenant members without `tenant_verification.acknowledge`: acknowledgement attempts fail with 403. + +## Run the focused test suite + +Once tests are implemented: + +- Run only the spec-related tests: `vendor/bin/sail artisan test --compact --filter=Verification` (or point at the specific test file(s)). + +## Formatting + +- Format only changed files before finalizing: `vendor/bin/sail bin pint --dirty` diff --git a/specs/075-verification-v1-5/research.md b/specs/075-verification-v1-5/research.md new file mode 100644 index 0000000..2ffcb7e --- /dev/null +++ b/specs/075-verification-v1-5/research.md @@ -0,0 +1,119 @@ +# Research: Verification Checklist Framework V1.5 (075) + +**Date**: 2026-02-05 +**Phase**: Phase 0 (Foundational Research) +**Status**: Complete + +--- + +## Decisions + +### D-075-001 — Canonical storage for report + metadata + +**Decision**: Store the verification report (including `fingerprint` and `previous_report_id`) inside `operation_runs.context.verification_report` (JSONB), consistent with 074. + +**Rationale**: +- Viewer surfaces must be DB-only at render time (constitution: Operations / Run Observability Standard). +- `OperationRun` is already the canonical operational record and stable viewer entry point. +- Adds supportability metadata without introducing a new top-level report table. + +**Alternatives considered**: +- Dedicated `verification_reports` table: rejected for v1.5 to avoid new query/index surfaces; revisit if we need global querying across reports. + +--- + +### D-075-002 — Report identity + “previous report” resolution + +**Decision**: Resolve `previous_report_id` by querying the most recent earlier `OperationRun` whose **run identity** matches exactly (flow/type, tenant, workspace, provider connection where applicable). + +**Rationale**: +- The existing `OperationRunService::ensureRunWithIdentity()` + `run_identity_hash` already defines the dedupe boundary. +- Matches the spec’s clarified rule: `provider_connection_id` must match exactly; `NULL` only matches `NULL`. + +**Alternatives considered**: +- Match previous runs by only `tenant_id + workspace_id + type` and then filter in PHP: rejected due to ambiguity and risk of cross-connection mixing. + +--- + +### D-075-003 — Report ID semantics + +**Decision**: Treat the `OperationRun` ID as the report identifier in UX and contracts (`report_id == operation_run_id`). + +**Rationale**: +- The report is attached to the run; the run is the stable, tenant-scoped canonical record. +- Avoids a second identifier for the same “verification execution artifact”. + +**Alternatives considered**: +- Generate a separate report UUID inside the JSON: rejected as it adds indirection without benefits in v1.5. + +--- + +### D-075-004 — Fingerprint algorithm + +**Decision**: Use SHA-256 over a deterministic normalization of check outcomes: +- flatten checks +- sort by `check.key` +- contribute `key|status|blocking|reason_code|severity` where `severity` is always present (missing → empty) + +Store as lowercase hex. + +**Rationale**: +- Deterministic across environments. +- Treats severity-only changes as meaningful (per clarified requirement). + +**Alternatives considered**: +- Hash the full report JSON: rejected (unstable ordering, non-semantic fields like timestamps). + +--- + +### D-075-005 — Per-check acknowledgements persistence + +**Decision**: Create a first-class table `verification_check_acknowledgements` keyed by `(operation_run_id, check_key)` with a unique constraint. + +**Rationale**: +- Acknowledgements are governance metadata and must be queryable and auditable. +- Unique per report/check is enforced by the DB. + +**Alternatives considered**: +- Store acknowledgements inside `operation_runs.context`: rejected as it complicates update semantics and auditability, and risks “report mutation” appearing like a changed verification outcome. + +--- + +### D-075-006 — Capability naming reconciliation + +**Decision**: Introduce a dedicated canonical capability `tenant_verification.acknowledge` in the capability registry and map it in the role → capability map. + +**Rationale**: +- Keeps the feature spec requirement literal and avoids overloading “findings” semantics. +- Preserves the constitution rule that capabilities are centrally registered (no raw strings). + +**Alternatives considered**: +- Reuse existing `tenant_findings.acknowledge`: rejected because this feature is specifically verification-report scoped, and we want the permission surface to remain explicit. + +--- + +### D-075-007 — Audit action identifier + payload minimization + +**Decision**: Add a stable audit action ID for acknowledgements (e.g. `verification.check_acknowledged`) and emit it on successful acknowledgement. Audit metadata is minimal and MUST NOT include `ack_reason`. + +**Rationale**: +- Acknowledgement is a write mutation; constitution requires audit logging. +- Spec explicitly excludes `ack_reason` from audit payload; it remains only in the acknowledgement record. + +**Alternatives considered**: +- Reuse `verification.completed`: rejected because it conflates verification execution with governance mutation. + +--- + +### D-075-008 — Filament UI implementation constraints + +**Decision**: Implement the “Verify step” UX changes in Filament v5 (Livewire v4) using: +- DB-only viewer helper (no external calls) +- centralized badge domains (BADGE-001) +- mutation via Filament `Action::make(...)->action(...)` with `->requiresConfirmation()` + +**Rationale**: +- Aligns with Filament v5 patterns and constitution rules. + +**Alternatives considered**: +- Publish/override Filament internal views: rejected; prefer render hooks + CSS hooks as needed. diff --git a/specs/075-verification-v1-5/spec.md b/specs/075-verification-v1-5/spec.md new file mode 100644 index 0000000..914124b --- /dev/null +++ b/specs/075-verification-v1-5/spec.md @@ -0,0 +1,225 @@ +# Feature Specification: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete) + +**Feature Branch**: `075-verification-v1_5` +**Created**: 2026-02-05 +**Status**: Draft +**Input**: User description: "Extend verification checklist framework with report fingerprint + previous report change indicator, per-check acknowledgements with audit/confirmation, and issues-first operator-ready verify-step UX." + +## Goal + +V1.5 extends the V1 verification checklist framework with two enterprise-critical additions while keeping scope intentionally small: + +1) **Supportability / determinism**: show whether results changed since the previous verification for the same identity. +2) **Governance**: allow explicit, auditable acknowledgement of known issues per failing check. +3) **Enterprise UX completeness**: make the Verify step operator-ready (issues-first, one clear primary action, technical details secondary). + +## Clarifications + +### Session 2026-02-05 + +- Q: How is “block” represented on checks? → A: No new status; a “Blocker” is `status=fail` with `blocking=true`. +- Q: Should `severity` be part of the fingerprint? → A: Yes; include `severity` always (normalize missing to empty) to keep hashing deterministic and to treat severity-only changes as “Changed”. +- Q: Should `ack_reason` be included in the audit event payload? → A: No; keep audit metadata minimal and store the reason only in the acknowledgement record. +- Q: How should `provider_connection_id` be treated when resolving `previous_report_id`? → A: Match exactly; `NULL` only matches `NULL` (no cross-connection mixing). +- **Report shape (canonical, inherited from 074)**: Persist reports as the existing V1 JSON shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`). V1.5 adds `fingerprint` + `previous_report_id` at the top level. No `sections[]` array is stored. +- **Idempotency (inherited)**: Deduplication applies only while a run is active (`queued` / `running`). Once `completed` / `failed`, starting verification creates a new run. +- **Viewing (inherited)**: Viewing is DB-only; rendering MUST NOT perform external calls. +- **Evidence (inherited)**: Evidence is limited to safe pointers only; no secrets (no tokens/claims/headers/raw payloads). +- **Next steps (inherited)**: Navigation-only links (no server-side “fix it” actions from the viewer). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Operator can tell “nothing changed” (Priority: P1) + +As an operator, I can immediately see whether the current verification findings are unchanged compared to the previous verification for the same identity, so I can avoid unnecessary re-diagnosis. + +**Why this priority**: This is the fastest path to supportability: it reduces repeated analysis and makes troubleshooting deterministic. + +**Independent Test**: Create two reports for the same identity with identical normalized check outcomes; confirm the viewer indicates “No changes since previous verification”. + +**Acceptance Scenarios**: + +1. **Given** a report with a previous report available, **When** I open the viewer, **Then** I see a clear indicator “Changed” or “No changes”. +2. **Given** the current report has the same fingerprint as the previous report, **When** I open the viewer, **Then** I see “No changes since previous verification”. + +--- + +### User Story 2 - Owner/Manager can acknowledge a known issue (Priority: P1) + +As an owner/manager, I can acknowledge a failing/warning/blocking check with a short reason (and optionally an expiry) so the team can see that the risk is known, evaluated, and accepted. + +**Why this priority**: Acknowledgements provide governance without masking risk; they improve shared context and auditability. + +**Independent Test**: With and without the acknowledgement capability, attempt to acknowledge a failing check; assert correct authorization (403) and that an audit event is recorded for the successful path. + +**Acceptance Scenarios**: + +1. **Given** a check in status `fail` / `warn` (including failing blockers where `blocking=true`), **When** I acknowledge it with a reason, **Then** the UI shows who acknowledged it, when, and the reason. +2. **Given** I do not have the acknowledgement capability, **When** I attempt to acknowledge a check, **Then** the server returns 403 and the UI does not offer the acknowledgement action. + +--- + +### User Story 3 - Verify step is operator-ready (issues-first) (Priority: P1) + +As a workspace member, I see issues-first results with clear next steps and exactly one primary action (start or refresh), so I can remediate quickly without hunting through technical details. + +**Why this priority**: The Verify step is a high-frequency operator surface; clarity and deterministic states reduce time-to-resolution. + +**Independent Test**: Seed a report with blockers and a running state; confirm the default tab and “one primary CTA” rule is enforced in both completed and running scenarios. + +**Acceptance Scenarios**: + +1. **Given** a report with blockers, **When** I open the Verify step/viewer, **Then** the Issues tab is the default and blockers are at the top. +2. **Given** a run is active, **When** I open the Verify step/viewer, **Then** the primary action is “Refresh results” and technical links are secondary. + +--- + +### Edge Cases + +- No previous report exists for an identity → no “changed/no-change” indicator is shown. +- Run is active but no report is available yet → UI shows a clear “running, results will appear” explanation (no empty states without guidance). +- Partial report while running → partial results render with a “Partial results” label. +- Unknown check keys or reason codes → UI degrades gracefully, showing status and message without breaking. +- Acknowledgement attempted for non-acknowledgeable status (e.g., `pass`) → request is rejected and UI does not offer it. + +## Out of Scope + +- Diff/compare UI between reports +- Server-side fixes initiated from the viewer +- Undo / unacknowledge acknowledgements (V1.5 acknowledgements are immutable per report) +- Complex staleness/TTL semantics (fresh/stale/expired) +- Global dashboards / cross-tenant reporting +- Export features (PDF/JSON) as a product feature +- Live polling (V1.5 uses manual refresh) + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds new tenant-scoped mutations (acknowledgements) and new report metadata. It MUST include explicit confirmation, audit logging for mutations, tenant isolation, and tests. + +**Constitution alignment (RBAC-UX):** Tenant-scoped routes MUST preserve deny-as-not-found (404) for non-members, and use 403 for members missing a capability. UI visibility is not authorization; server-side enforcement is required. + +**Constitution alignment (BADGE-001):** Status-like badges MUST use centralized mapping semantics; no ad-hoc UI mappings. + +### Functional Requirements + +- **FR-075-001 — Report fingerprint**: Each verification report MUST store a deterministic `fingerprint` derived from normalized check outcomes. + + **Normalization rule (deterministic):** + - Flatten all check results across `report.checks[]` + - Sort by stable `check.key` + - For each check, contribute a stable string using: `key | status | blocking | reason_code | severity` + - `severity` MUST be included always; if the source report omits it, normalize to an empty string + - The fingerprint MUST be a stable cryptographic hash of the joined contributions, stored as a fixed-length lowercase hex string. + +- **FR-075-002 — Previous report link**: Each report MUST store `previous_report_id` (nullable) that points to the most recent earlier report for the same **verification identity**. + + **Identity match** MUST include: + - flow + - workspace + - tenant + - provider connection (`provider_connection_id`) matched exactly; `NULL` only matches `NULL` + +- **FR-075-003 — Change indicator**: When a previous report exists, the viewer MUST show: + - “No changes since previous verification” if `fingerprint` matches + - “Changed since previous verification” otherwise + +- **FR-075-004 — Per-check acknowledgements (first-class)**: The system MUST allow acknowledging checks with status `fail` / `warn`. + + An acknowledgement MUST record: + - reason (max 160 characters) + - acknowledged timestamp + - acknowledged-by user + - optional expiry timestamp + + Acknowledgements MUST be unique per (report, check key). Expiry, when provided, is informational only in V1.5 and MUST NOT introduce automatic staleness/TTL behavior. + +- **FR-075-005 — Acknowledgement does not change outcomes**: Acknowledging MUST NOT change: + - the check status + - the report summary status/outcome + - the run outcome + +- **FR-075-006 — Acknowledgement allowed conditions**: Acknowledgement MUST only be possible for checks whose status is in `{fail, warn}`. It MUST NOT be available for passing/green checks. + + A check is considered a **Blocker** when `status=fail` and `blocking=true`; blockers are acknowledgeable under the same `{fail, warn}` rule (no separate `block` status exists). + +- **FR-075-007 — Acknowledgement authorization (capability-first)**: Acknowledgement MUST require the capability `tenant_verification.acknowledge` as defined in the canonical capability registry. + + RBAC UX semantics: + - non-member / not entitled to tenant scope → 404 + - member without acknowledgement capability → 403 + - members with tenant scope but without acknowledgement capability can still view reports (view remains read-only) + +- **FR-075-007A — Viewing authorization semantics preserved (inherited)**: Viewing tenant-scoped verification pages (Verify step + report viewer) MUST preserve V1 semantics: + - non-member / not entitled to tenant scope → 404 + - member with tenant scope → can view + - capability checks apply to mutations only (start verification, acknowledgement) + +- **FR-075-008 — Confirmation + audit required**: Acknowledgement is a mutation and MUST require explicit user confirmation and MUST emit an audit event. + +- **FR-075-009 — Audit event metadata (minimal)**: The audit event for acknowledgement MUST include minimally: + - workspace, tenant, run, report, flow + - check key and reason code + - acknowledged-by user + + It MUST NOT include `ack_reason`, secrets, tokens, or raw payloads. + +- **FR-075-010 — DB-only viewing guard (inherited)**: Rendering the viewer and the Verify step MUST NOT trigger external calls. + +- **FR-075-011 — Centralized badge semantics (BADGE-001)**: All check-status badges and summary-status badges used by V1.5 MUST use the centralized badge mapping registry. + +- **FR-075-012 — Verify step enterprise UX (normative)**: The Verify step/viewer MUST follow an issues-first layout and deterministic UI states: + + **Structure** + - Always-visible summary card + - Tabs: Issues (default), Passed, Technical details + + **DB-only hint** + - The summary surface MUST include a clear hint that viewing is read-only and performs no external calls. + + **Primary action rule (strict)** + - Exactly one primary call-to-action is shown at any time + - “Start verification” and “Refresh results” MUST NOT both be primary simultaneously + + **Issues tab ordering** + 1) Blockers (not acknowledged) + 2) Failures (not acknowledged) + 3) Warnings (not acknowledged) + 4) Acknowledged issues (collapsed group) + + **Next steps rendering** + - Max 2 navigation-only links per issue card + - “Open run details” MUST appear only in Technical details (not in issue cards) + + **Technical details** + - Secondary surface that can show identifiers (run/report IDs), fingerprint, and previous report link + - No raw payloads/tokens/full error bodies + +### Key Entities *(include if feature involves data)* + +- **Verification Identity**: The stable identifiers that define “what is being verified” (flow, workspace, tenant, and optional provider connection). +- **Verification Report**: A structured record of verification outcomes for a run. +- **Report Fingerprint**: A deterministic hash representing normalized check outcomes. +- **Previous Report**: The immediately preceding report for the same identity. +- **Check Acknowledgement**: A governance record that an issue is known/accepted (who/when/reason/optional expiry) without altering the check outcome. + +### Assumptions + +- A verification run/report concept already exists from V1. +- The system has an audit log mechanism capable of recording acknowledgement actions. +- Manual refresh is acceptable (no polling required). + +### Dependencies + +- Spec 074 (Verification Checklist Framework V1) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-075-001 (Supportability)**: With a previous report present, operators can determine “changed vs no changes” within 10 seconds in 95% of tested sessions. +- **SC-075-002 (Governance)**: 100% of successful acknowledgements create an audit log record with minimal metadata and no sensitive content. +- **SC-075-003 (UX determinism)**: The Verify step renders exactly one primary CTA in all tested UI states (not started, running with/without report, completed). +- **SC-075-004 (Authorization correctness)**: Non-members receive 404 for tenant-scoped access routes in 100% of tests; members without acknowledgement capability receive 403 for acknowledgement attempts in 100% of tests. +- **SC-075-005 (No greenwashing)**: Acknowledging an issue never changes check status or the report summary in any tested scenario. + +``` diff --git a/specs/075-verification-v1-5/tasks.md b/specs/075-verification-v1-5/tasks.md new file mode 100644 index 0000000..9ced43a --- /dev/null +++ b/specs/075-verification-v1-5/tasks.md @@ -0,0 +1,172 @@ +--- + +description: "Task breakdown for Spec 075 (Verification Checklist Framework V1.5)" +--- + +# Tasks: Verification Checklist Framework V1.5 (075) + +**Input**: Design documents from `/specs/075-verification-v1-5/` + +**Tests**: REQUIRED (Pest) + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Align feature artifacts with the existing 074 verification implementation (report shape, DB-only viewing constraints). + +- [X] T001 Reconcile v1.5 report contract to match the V1 report shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`) + v1.5 fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json +- [X] T002 [P] Confirm viewer surfaces are DB-only using existing guard helpers in tests/Support/AssertsNoOutboundHttp.php (helper availability + correct usage patterns) +- [X] T003 [P] Identify all report viewer templates to update: resources/views/filament/components/verification-report-viewer.blade.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared primitives required by all user stories (schema/sanitization, stable fingerprint, previous report resolution). + +**⚠️ CRITICAL**: Complete this phase before implementing US1/US2/US3. + +- [X] T004 Update report schema to allow v1.5 metadata fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSchema.php +- [X] T005 Update report sanitizer to preserve v1.5 metadata fields (`fingerprint`, `previous_report_id`) and preserve empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSanitizer.php +- [X] T006 [P] Add a deterministic fingerprint helper in app/Support/Verification/VerificationReportFingerprint.php (flatten `checks[]`; normalize missing `severity` to empty string, not `info`) +- [X] T007 Add a previous-report resolver helper in app/Support/Verification/PreviousVerificationReportResolver.php +- [X] T008 [P] Add or update verification badge mapping tests in tests/Feature/Badges/ to cover all v1.5-used status-like values (BADGE-001) + +**Checkpoint**: Schema + sanitizer accept v1.5 fields; fingerprint + previous-report resolver are available for use. + +--- + +## Phase 3: User Story 1 — Operator can tell “nothing changed” (Priority: P1) 🎯 MVP + +**Goal**: Persist a deterministic `fingerprint` + `previous_report_id` on each report, and show “Changed / No changes” when a previous report exists. + +**Independent Test**: Create two completed verification runs for the same identity with identical normalized outcomes; confirm viewer indicates “No changes since previous verification”. + +### Tests for User Story 1 (write first) + +- [X] T009 [P] [US1] Add fingerprint determinism unit tests in tests/Feature/Verification/VerificationReportFingerprintTest.php (including missing severity → empty string, and severity-only changes → different hash) +- [X] T010 [P] [US1] Add previous report identity matching tests (provider_connection_id exact match; NULL matches NULL) and a regression proving cross-connection runs don’t match when run_identity_hash includes provider_connection_id in tests/Feature/Verification/PreviousVerificationReportResolverTest.php + +### Implementation for User Story 1 + +- [X] T011 [US1] Compute and persist report fingerprint in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/VerificationReportFingerprint.php) +- [X] T012 [US1] Resolve and persist previous_report_id during write in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/PreviousVerificationReportResolver.php + run_identity_hash; verify all verification run start paths include provider_connection_id in identityInputs) +- [X] T013 [P] [US1] Extend DB-only report viewer helper to expose v1.5 metadata in app/Filament/Support/VerificationReportViewer.php +- [X] T014 [US1] Add change-indicator computation for viewer surfaces in app/Filament/Support/VerificationReportChangeIndicator.php + +**Checkpoint**: Report JSON includes `fingerprint` + `previous_report_id`; viewer can derive Changed/No changes. + +--- + +## Phase 4: User Story 2 — Owner/Manager can acknowledge a known issue (Priority: P1) + +**Goal**: Acknowledge `fail` / `warn` checks per report with confirmation + audit, without changing check outcomes. + +**Independent Test**: Attempt to acknowledge a failing check (a) as non-member → 404, (b) as member without capability → 403, (c) with capability → record created + audit logged. + +### Tests for User Story 2 (write first) + +- [X] T015 [P] [US2] Add acknowledgement authorization + audit tests in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php (404 non-member, 403 missing capability, persists optional expires_at; audit metadata includes check_key + reason_code and excludes ack_reason) + +### Implementation for User Story 2 + +- [X] T016 [US2] Create migration for verification_check_acknowledgements table (includes optional expires_at; informational only) in database/migrations/*_create_verification_check_acknowledgements_table.php +- [X] T017 [P] [US2] Create model in app/Models/VerificationCheckAcknowledgement.php +- [X] T018 [P] [US2] Create factory for acknowledgements in database/factories/VerificationCheckAcknowledgementFactory.php +- [X] T019 [US2] Implement acknowledgement creation service in app/Services/Verification/VerificationCheckAcknowledgementService.php (server-side authorization via Gate/policy; validate status ∈ {fail,warn}; validate optional expires_at; enforce unique per (operation_run_id, check_key)) +- [X] T020 [P] [US2] Register capability constant tenant_verification.acknowledge in app/Support/Auth/Capabilities.php +- [X] T021 [P] [US2] Map tenant_verification.acknowledge to tenant roles in app/Services/Auth/RoleCapabilityMap.php +- [X] T022 [P] [US2] Add audit action id for acknowledgement in app/Support/Audit/AuditActionId.php (e.g. verification.check_acknowledged) +- [X] T023 [US2] Emit audit event with minimal metadata via app/Services/Audit/WorkspaceAuditLogger.php from the acknowledgement path (MUST include: tenant_id, operation_run_id/report_id, flow, check_key, reason_code; MUST NOT include ack_reason) + +**Checkpoint**: Acknowledgements are persisted, authorized, confirmed in UI (next story), and audited with minimized metadata. + +--- + +## Phase 5: User Story 3 — Verify step is operator-ready (issues-first) (Priority: P1) + +**Goal**: Issues-first view, centralized badge semantics (BADGE-001), DB-only hint, and exactly one primary CTA depending on state. + +**Independent Test**: Seed a run with blockers while completed and while running; confirm Issues is default, ordering rules hold, and one-primary-CTA rule holds. + +### Tests for User Story 3 (write first) + +- [X] T024 [P] [US3] Add Verify-step CTA and ordering tests in tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php +- [X] T025 [P] [US3] Add DB-only render guard test coverage for Verify surfaces in tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php + +### Implementation for User Story 3 + +- [X] T026 [US3] Enforce “exactly one primary CTA” logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start vs refresh) +- [X] T027 [US3] Refactor Verify-step report view to issues-first tabs + ordering + DB-only hint in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php +- [X] T028 [US3] Add per-check acknowledgement action UI with confirmation in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Action::make(...)->action(...)->requiresConfirmation()) +- [X] T029 [US3] Wire acknowledgement UI to service + RBAC semantics (404 non-member, 403 missing capability; server-side enforcement required) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +- [X] T030 [US3] Update the Monitoring viewer to match v1.5 UX rules (issues-first tabs: Issues default, Passed, Technical details; ordering; next-steps max 2) in resources/views/filament/components/verification-report-viewer.blade.php +- [X] T031 [P] [US3] Show change indicator + previous report link in technical details (no raw payloads) in resources/views/filament/components/verification-report-viewer.blade.php + +**Checkpoint**: Verify UX is deterministic, issues-first, and operator-ready across onboarding and monitoring surfaces. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Hardening, formatting, and regression coverage. + +- [X] T032 [P] Ensure acknowledgement does not mutate check status/summary in app/Support/Verification/VerificationReportWriter.php and cover with assertions in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php +- [X] T033 [P] Add redaction regression checks for new v1.5 fields (fingerprint/previous_report_id) in tests/Feature/Verification/VerificationReportRedactionTest.php +- [X] T034 [P] Run Pint on changed files via vendor/bin/sail bin pint --dirty +- [X] T035 Run focused test suite via vendor/bin/sail artisan test --compact --filter=Verification + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: start immediately +- **Foundational (Phase 2)**: blocks all user stories +- **User Stories (Phase 3–5)**: + - US1 depends on Phase 2 + - US2 depends on Phase 2 + - US3 depends on Phase 2 and benefits from US1 + US2 completion +- **Polish (Phase 6)**: after US1–US3 + +### User Story Dependencies (Graph) + +- **US1 (Fingerprint + previous report + changed indicator)** → enables technical details and “Changed/No changes” banner in US3 +- **US2 (Acknowledgements)** → enables “Acknowledged issues” grouping and action UX in US3 +- **US3 (Verify UX)** → integrates outputs of US1 + US2 into operator surface + +--- + +## Parallel Execution Examples + +### US1 + +- Run in parallel: + - T009 (fingerprint determinism tests) + T010 (previous resolver tests) + - T013 (viewer helper exposure) can proceed while T011/T012 land + +### US2 + +- Run in parallel: + - T017 (model) + T018 (factory) + T020 (capability constant) + T021 (role mapping) + T022 (audit action id) + +### US3 + +- Run in parallel: + - T024 (UX tests) + T025 (DB-only tests) + - T027 (onboarding blade refactor) + T030 (monitoring viewer refactor) + +--- + +## Implementation Strategy + +### MVP First (US1) + +1. Phase 1 → Phase 2 +2. Implement US1 (Phase 3) +3. Validate: run T035 and confirm “No changes since previous verification” path + +### Incremental Delivery + +1. US1 (supportability) → US2 (governance) → US3 (operator UX) +2. After each story, run story-specific tests plus `vendor/bin/sail artisan test --compact --filter=Verification` diff --git a/tests/Feature/Badges/VerificationBadgeSemanticsTest.php b/tests/Feature/Badges/VerificationBadgeSemanticsTest.php new file mode 100644 index 0000000..2873a01 --- /dev/null +++ b/tests/Feature/Badges/VerificationBadgeSemanticsTest.php @@ -0,0 +1,47 @@ +label)->toBe('Fail'); + expect($spec->color)->toBe('danger'); + expect($spec->icon)->toBe('heroicon-m-x-circle'); +}); + +it('normalizes verification check status input before mapping', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'RUNNING'); + + expect($spec->label)->toBe('Running'); +}); + +it('maps verification check severity critical to a Critical danger badge', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical'); + + expect($spec->label)->toBe('Critical'); + expect($spec->color)->toBe('danger'); + expect($spec->icon)->toBe('heroicon-m-x-circle'); +}); + +it('maps empty verification check severity to an Unknown badge (v1.5 allows empty severity)', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, ''); + + expect($spec->label)->toBe('Unknown'); +}); + +it('maps verification report overall needs_attention to a Needs attention warning badge', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention'); + + expect($spec->label)->toBe('Needs attention'); + expect($spec->color)->toBe('warning'); + expect($spec->icon)->toBe('heroicon-m-exclamation-triangle'); +}); + +it('normalizes verification report overall input before mapping', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'NEEDS ATTENTION'); + + expect($spec->label)->toBe('Needs attention'); +}); + diff --git a/tests/Feature/Onboarding/OnboardingVerificationTest.php b/tests/Feature/Onboarding/OnboardingVerificationTest.php index f793680..4a71f28 100644 --- a/tests/Feature/Onboarding/OnboardingVerificationTest.php +++ b/tests/Feature/Onboarding/OnboardingVerificationTest.php @@ -10,6 +10,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Verification\VerificationReportWriter; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -102,13 +103,19 @@ 'entra_tenant_id' => $entraTenantId, 'entra_tenant_name' => 'Contoso', ], - ], - 'failure_summary' => [ - [ - 'code' => 'provider.connection.check.failed', - 'reason_code' => 'permission_denied', - 'message' => 'Missing required Graph permissions.', - ], + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'permission_check', + 'title' => 'Graph permissions', + 'status' => 'fail', + 'severity' => 'high', + 'blocking' => true, + 'reason_code' => 'permission_denied', + 'message' => 'Missing required Graph permissions.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), ], ]); @@ -127,7 +134,7 @@ $this->actingAs($user) ->get('/admin/onboarding') ->assertSuccessful() - ->assertSee('permission_denied') ->assertSee('Missing required Graph permissions.') + ->assertSee('Graph permissions') ->assertSee($entraTenantId); }); diff --git a/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php b/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php new file mode 100644 index 0000000..d44b2da --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php @@ -0,0 +1,222 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + ]); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'is_default' => true, + ]); + + TenantOnboardingSession::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'current_step' => 'verify', + 'state' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'started_by_user_id' => (int) $user->getKey(), + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user) + ->get('/admin/onboarding') + ->assertSuccessful() + ->assertSee('Start verification') + ->assertDontSee('Refresh'); + + $runningRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'running', + 'outcome' => 'pending', + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->update([ + 'state' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $runningRun->getKey(), + ], + ]); + + $this->actingAs($user) + ->get('/admin/onboarding') + ->assertSuccessful() + ->assertSee('Refresh') + ->assertDontSee('Start verification'); +}); + +it('orders issues deterministically and groups acknowledged issues', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + ]); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'is_default' => true, + ]); + + $checks = [ + [ + 'key' => 'acknowledged_fail', + 'title' => 'Acked failure', + 'status' => 'fail', + 'severity' => 'medium', + 'blocking' => false, + 'reason_code' => 'invalid_state', + 'message' => 'Already known.', + 'evidence' => [], + 'next_steps' => [], + ], + [ + 'key' => 'warning', + 'title' => 'Warning check', + 'status' => 'warn', + 'severity' => 'low', + 'blocking' => false, + 'reason_code' => 'not_applicable', + 'message' => 'Something is slightly off.', + 'evidence' => [], + 'next_steps' => [], + ], + [ + 'key' => 'failure', + 'title' => 'Failure check', + 'status' => 'fail', + 'severity' => 'high', + 'blocking' => false, + 'reason_code' => 'missing_configuration', + 'message' => 'This must be fixed.', + 'evidence' => [], + 'next_steps' => [], + ], + [ + 'key' => 'blocker', + 'title' => 'Blocker check', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => 'permission_denied', + 'message' => 'Cannot proceed.', + 'evidence' => [], + 'next_steps' => [ + ['label' => 'First step', 'url' => '/admin/help/first'], + ['label' => 'Second step', 'url' => '/admin/help/second'], + ['label' => 'Third step', 'url' => '/admin/help/third'], + ], + ], + [ + 'key' => 'pass', + 'title' => 'Passed check', + 'status' => 'pass', + 'severity' => '', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Looks good.', + 'evidence' => [], + 'next_steps' => [], + ], + ]; + + $report = VerificationReportWriter::build('provider.connection.check', $checks); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'failed', + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_report' => $report, + ], + ]); + + VerificationCheckAcknowledgement::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'check_key' => 'acknowledged_fail', + 'ack_reason' => 'Known issue accepted.', + 'acknowledged_by_user_id' => (int) $user->getKey(), + 'acknowledged_at' => now(), + ]); + + TenantOnboardingSession::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'current_step' => 'verify', + 'state' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + 'started_by_user_id' => (int) $user->getKey(), + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user) + ->get('/admin/onboarding') + ->assertSuccessful() + ->assertSee('Read-only') + ->assertSeeInOrder([ + 'Blocker check', + 'Failure check', + 'Warning check', + 'Acknowledged issues', + 'Acked failure', + ]) + ->assertSee('Known issue accepted.') + ->assertSee('First step') + ->assertSee('Second step') + ->assertDontSee('Third step'); +}); diff --git a/tests/Feature/Verification/PreviousVerificationReportResolverTest.php b/tests/Feature/Verification/PreviousVerificationReportResolverTest.php new file mode 100644 index 0000000..99f9860 --- /dev/null +++ b/tests/Feature/Verification/PreviousVerificationReportResolverTest.php @@ -0,0 +1,124 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connectionId = (int) ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ])->getKey(); + + $previous = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'run_identity_hash' => 'same-hash', + 'context' => [ + 'provider_connection_id' => $connectionId, + ], + ]); + + $current = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'run_identity_hash' => 'same-hash', + 'context' => [ + 'provider_connection_id' => $connectionId, + ], + ]); + + expect(PreviousVerificationReportResolver::resolvePreviousReportId($current)) + ->toBe((int) $previous->getKey()); +}); + +it('does not resolve previous report ids across provider connections', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connectionA = (int) ProviderConnection::factory()->create(['tenant_id' => (int) $tenant->getKey()])->getKey(); + $connectionB = (int) ProviderConnection::factory()->create(['tenant_id' => (int) $tenant->getKey()])->getKey(); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'run_identity_hash' => 'same-hash', + 'context' => [ + 'provider_connection_id' => $connectionA, + ], + ]); + + $current = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'run_identity_hash' => 'same-hash', + 'context' => [ + 'provider_connection_id' => $connectionB, + ], + ]); + + expect(PreviousVerificationReportResolver::resolvePreviousReportId($current)) + ->toBeNull(); +}); + +it('includes provider_connection_id in the verification run identity hash (no cross-connection dedupe)', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connectionA = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $connectionB = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + ]); + + $starter = app(StartVerification::class); + + $runA = $starter->providerConnectionCheck( + tenant: $tenant, + connection: $connectionA, + initiator: $user, + )->run->refresh(); + + $runB = $starter->providerConnectionCheck( + tenant: $tenant, + connection: $connectionB, + initiator: $user, + )->run->refresh(); + + expect($runA->getKey())->not->toBe($runB->getKey()); + expect($runA->run_identity_hash)->not->toBe($runB->run_identity_hash); +}); + diff --git a/tests/Feature/Verification/VerificationCheckAcknowledgementTest.php b/tests/Feature/Verification/VerificationCheckAcknowledgementTest.php new file mode 100644 index 0000000..0239009 --- /dev/null +++ b/tests/Feature/Verification/VerificationCheckAcknowledgementTest.php @@ -0,0 +1,180 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'readonly'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'failed', + 'context' => [ + 'verification_report' => json_decode( + (string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')), + true, + 512, + JSON_THROW_ON_ERROR, + ), + ], + ]); + + expect(fn () => app(VerificationCheckAcknowledgementService::class)->acknowledge( + tenant: $tenant, + run: $run, + checkKey: 'provider_connection.token_acquisition', + ackReason: 'Known issue', + expiresAt: null, + actor: $user, + ))->toThrow(NotFoundHttpException::class); +}); + +it('returns 403 for members without tenant_verification.acknowledge on verification check acknowledgement', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'failed', + 'context' => [ + 'verification_report' => json_decode( + (string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')), + true, + 512, + JSON_THROW_ON_ERROR, + ), + ], + ]); + + expect(fn () => app(VerificationCheckAcknowledgementService::class)->acknowledge( + tenant: $tenant, + run: $run, + checkKey: 'provider_connection.token_acquisition', + ackReason: 'Known issue', + expiresAt: null, + actor: $user, + ))->toThrow(AuthorizationException::class); +}); + +it('acknowledges a failing check (with optional expiry) and writes a minimal audit log (no ack_reason)', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'failed', + 'context' => [ + 'verification_report' => json_decode( + (string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')), + true, + 512, + JSON_THROW_ON_ERROR, + ), + ], + ]); + + $reportBefore = $run->context['verification_report'] ?? null; + $reportBefore = is_array($reportBefore) ? $reportBefore : []; + + $summaryBefore = is_array($reportBefore['summary'] ?? null) ? $reportBefore['summary'] : []; + $countsBefore = is_array($summaryBefore['counts'] ?? null) ? $summaryBefore['counts'] : []; + + $checksBefore = is_array($reportBefore['checks'] ?? null) ? $reportBefore['checks'] : []; + $checksBeforeByKey = collect($checksBefore) + ->filter(fn ($check): bool => is_array($check) && is_string($check['key'] ?? null)) + ->mapWithKeys(fn (array $check): array => [ + (string) $check['key'] => [ + 'status' => $check['status'] ?? null, + 'blocking' => $check['blocking'] ?? null, + 'severity' => $check['severity'] ?? null, + 'reason_code' => $check['reason_code'] ?? null, + ], + ]) + ->all(); + + $fingerprintBefore = VerificationReportFingerprint::forReport($reportBefore); + + $expiresAt = now()->addDay()->toISOString(); + + $ack = app(VerificationCheckAcknowledgementService::class)->acknowledge( + tenant: $tenant, + run: $run, + checkKey: 'provider_connection.token_acquisition', + ackReason: 'Known issue', + expiresAt: $expiresAt, + actor: $user, + ); + + expect($ack->operation_run_id)->toBe((int) $run->getKey()); + expect($ack->check_key)->toBe('provider_connection.token_acquisition'); + expect($ack->ack_reason)->toBe('Known issue'); + expect($ack->expires_at)->not->toBeNull(); + + $run->refresh(); + + $reportAfter = $run->context['verification_report'] ?? null; + $reportAfter = is_array($reportAfter) ? $reportAfter : []; + + $summaryAfter = is_array($reportAfter['summary'] ?? null) ? $reportAfter['summary'] : []; + $countsAfter = is_array($summaryAfter['counts'] ?? null) ? $summaryAfter['counts'] : []; + + expect($summaryAfter['overall'] ?? null)->toBe($summaryBefore['overall'] ?? null); + expect($countsAfter)->toBe($countsBefore); + + $checksAfter = is_array($reportAfter['checks'] ?? null) ? $reportAfter['checks'] : []; + $checksAfterByKey = collect($checksAfter) + ->filter(fn ($check): bool => is_array($check) && is_string($check['key'] ?? null)) + ->mapWithKeys(fn (array $check): array => [ + (string) $check['key'] => [ + 'status' => $check['status'] ?? null, + 'blocking' => $check['blocking'] ?? null, + 'severity' => $check['severity'] ?? null, + 'reason_code' => $check['reason_code'] ?? null, + ], + ]) + ->all(); + + expect($checksAfterByKey)->toBe($checksBeforeByKey); + + $fingerprintAfter = VerificationReportFingerprint::forReport($reportAfter); + expect($fingerprintAfter)->toBe($fingerprintBefore); + + $audit = AuditLog::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('action', AuditActionId::VerificationCheckAcknowledged->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit?->metadata)->toMatchArray([ + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'report_id' => (int) $run->getKey(), + 'flow' => 'provider.connection.check', + 'check_key' => 'provider_connection.token_acquisition', + 'reason_code' => 'authentication_failed', + ]); + + $metadata = $audit?->metadata ?? []; + + expect($metadata)->not->toHaveKey('ack_reason'); + expect(json_encode($metadata))->not->toContain('Known issue'); +}); diff --git a/tests/Feature/Verification/VerificationReportFingerprintTest.php b/tests/Feature/Verification/VerificationReportFingerprintTest.php new file mode 100644 index 0000000..661f45c --- /dev/null +++ b/tests/Feature/Verification/VerificationReportFingerprintTest.php @@ -0,0 +1,47 @@ + 'b', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'missing_configuration', 'severity' => 'high'], + ['key' => 'a', 'status' => 'pass', 'blocking' => false, 'reason_code' => 'ok', 'severity' => 'info'], + ]; + + $checksB = [ + ['key' => 'a', 'status' => 'pass', 'blocking' => false, 'reason_code' => 'ok', 'severity' => 'info'], + ['key' => 'b', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'missing_configuration', 'severity' => 'high'], + ]; + + expect(VerificationReportFingerprint::forChecks($checksA)) + ->toBe(VerificationReportFingerprint::forChecks($checksB)); +}); + +it('treats missing severity as empty string for fingerprint determinism', function (): void { + $withMissingSeverity = [ + ['key' => 'a', 'status' => 'fail', 'blocking' => true, 'reason_code' => 'permission_denied'], + ]; + + $withEmptySeverity = [ + ['key' => 'a', 'status' => 'fail', 'blocking' => true, 'reason_code' => 'permission_denied', 'severity' => ''], + ]; + + expect(VerificationReportFingerprint::forChecks($withMissingSeverity)) + ->toBe(VerificationReportFingerprint::forChecks($withEmptySeverity)); +}); + +it('treats severity-only changes as different fingerprints (missing != info)', function (): void { + $missingSeverity = [ + ['key' => 'a', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'unknown_error'], + ]; + + $infoSeverity = [ + ['key' => 'a', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'unknown_error', 'severity' => 'info'], + ]; + + expect(VerificationReportFingerprint::forChecks($missingSeverity)) + ->not->toBe(VerificationReportFingerprint::forChecks($infoSeverity)); +}); + diff --git a/tests/Feature/Verification/VerificationReportRedactionTest.php b/tests/Feature/Verification/VerificationReportRedactionTest.php index 38b192d..8c2171a 100644 --- a/tests/Feature/Verification/VerificationReportRedactionTest.php +++ b/tests/Feature/Verification/VerificationReportRedactionTest.php @@ -4,6 +4,7 @@ use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun; use App\Models\OperationRun; +use App\Support\Verification\VerificationReportFingerprint; use Filament\Facades\Filament; use Livewire\Livewire; @@ -21,12 +22,9 @@ JSON_THROW_ON_ERROR, ); - $report['checks'][0]['evidence'][] = ['kind' => 'authorization', 'value' => 'Bearer abc.def.ghi']; - $report['checks'][0]['evidence'][] = ['kind' => 'access_token', 'value' => 'super-secret']; - $report['checks'][0]['message'] = 'Authorization: Bearer abc.def.ghi access_token=super-secret'; - - $run = OperationRun::factory()->create([ + $previousRun = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, 'user_id' => (int) $user->getKey(), 'type' => 'provider.connection.check', 'status' => 'completed', @@ -36,9 +34,30 @@ ], ]); - assertNoOutboundHttp(function () use ($run): void { + $report['checks'][0]['evidence'][] = ['kind' => 'authorization', 'value' => 'Bearer abc.def.ghi']; + $report['checks'][0]['evidence'][] = ['kind' => 'access_token', 'value' => 'super-secret']; + $report['checks'][0]['message'] = 'Authorization: Bearer abc.def.ghi access_token=super-secret'; + $report['previous_report_id'] = (int) $previousRun->getKey(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'failed', + 'context' => [ + 'verification_report' => $report, + ], + ]); + + $fingerprint = VerificationReportFingerprint::forReport($report); + + assertNoOutboundHttp(function () use ($run, $fingerprint): void { Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()]) ->assertSee('Verification report') + ->assertSee('Open previous verification') + ->assertSee($fingerprint) ->assertSee('Token acquisition works') ->assertDontSee('access_token') ->assertDontSee('Bearer abc.def.ghi') diff --git a/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php b/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php index dd09b72..7acfcdb 100644 --- a/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php +++ b/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php @@ -4,8 +4,17 @@ use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun; use App\Models\OperationRun; +use App\Models\ProviderConnection; +use App\Models\Tenant; +use App\Models\TenantOnboardingSession; +use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; +use App\Support\Verification\VerificationReportWriter; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; use Livewire\Livewire; it('renders the verification report viewer DB-only (no outbound HTTP, no job dispatch)', function (): void { @@ -48,3 +57,79 @@ Bus::assertNothingDispatched(); }); + +it('renders onboarding verify surfaces DB-only (no outbound HTTP, no job/queue dispatch)', function (): void { + Bus::fake(); + Queue::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + ]); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'is_default' => true, + ]); + + $report = VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'onboarding_check', + 'title' => 'Onboarding check', + 'status' => 'fail', + 'severity' => 'high', + 'blocking' => false, + 'reason_code' => 'missing_configuration', + 'message' => 'Setup missing.', + 'evidence' => [], + 'next_steps' => [], + ], + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'failed', + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_report' => $report, + ], + ]); + + TenantOnboardingSession::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'current_step' => 'verify', + 'state' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + 'started_by_user_id' => (int) $user->getKey(), + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + assertNoOutboundHttp(function () use ($user): void { + $this->actingAs($user) + ->get('/admin/onboarding') + ->assertSuccessful() + ->assertSee('Onboarding check'); + }); + + Bus::assertNothingDispatched(); + Queue::assertNothingPushed(); +}); diff --git a/tests/Unit/AuditContextSanitizerTest.php b/tests/Unit/AuditContextSanitizerTest.php new file mode 100644 index 0000000..526549e --- /dev/null +++ b/tests/Unit/AuditContextSanitizerTest.php @@ -0,0 +1,20 @@ +toBe('provider.connection.check'); +}); + +it('redacts jwt-like strings', function (): void { + $jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + .'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.' + .'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + + expect(AuditContextSanitizer::sanitize($jwt)) + ->toBe('[REDACTED]'); +}); +