Compare commits
1 Commits
074-verifi
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 439248ba15 |
@ -5,8 +5,8 @@
|
|||||||
namespace App\Filament\Pages\Workspaces;
|
namespace App\Filament\Pages\Workspaces;
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -15,15 +15,17 @@
|
|||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationRegistry;
|
use App\Services\Providers\ProviderOperationRegistry;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
@ -37,6 +39,7 @@
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Text;
|
use Filament\Schemas\Components\Text;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Components\View;
|
||||||
use Filament\Schemas\Components\Wizard;
|
use Filament\Schemas\Components\Wizard;
|
||||||
use Filament\Schemas\Components\Wizard\Step;
|
use Filament\Schemas\Components\Wizard\Step;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@ -236,14 +239,21 @@ public function content(Schema $schema): Schema
|
|||||||
->schema([
|
->schema([
|
||||||
Section::make('Verification')
|
Section::make('Verification')
|
||||||
->schema([
|
->schema([
|
||||||
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
|
View::make('filament.components.verification-report-viewer')
|
||||||
->badge()
|
->viewData(fn (): array => [
|
||||||
->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'),
|
'report' => $this->verificationReport(),
|
||||||
|
]),
|
||||||
SchemaActions::make([
|
SchemaActions::make([
|
||||||
Action::make('wizardStartVerification')
|
UiEnforcement::forTableAction(
|
||||||
->label('Start verification')
|
Action::make('wizardStartVerification')
|
||||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
->label('Start verification')
|
||||||
->action(fn () => $this->startVerification()),
|
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
||||||
|
->action(fn () => $this->startVerification()),
|
||||||
|
fn (): ?Tenant => $this->managedTenant,
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
Action::make('wizardViewVerificationRun')
|
Action::make('wizardViewVerificationRun')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(fn (): ?string => $this->verificationRunUrl())
|
->url(fn (): ?string => $this->verificationRunUrl())
|
||||||
@ -467,6 +477,37 @@ private function verificationRunUrl(): ?string
|
|||||||
return OperationRunLinks::view($runId, $this->managedTenant);
|
return OperationRunLinks::view($runId, $this->managedTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function verificationReport(): ?array
|
||||||
|
{
|
||||||
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_int($runId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $this->managedTenant->getKey())
|
||||||
|
->whereKey($runId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationReportViewer::report($run);
|
||||||
|
}
|
||||||
|
|
||||||
private function bootstrapRunsLabel(): string
|
private function bootstrapRunsLabel(): string
|
||||||
{
|
{
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
@ -819,6 +860,24 @@ public function startVerification(): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
$workspaceMembership = WorkspaceMembership::query()
|
||||||
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$role = is_string($workspaceMembership?->role ?? null) ? (string) $workspaceMembership->role : 'readonly';
|
||||||
|
|
||||||
|
app(TenantMembershipManager::class)->addMember(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $user,
|
||||||
|
member: $user,
|
||||||
|
role: $role,
|
||||||
|
source: 'manual',
|
||||||
|
sourceRef: 'managed_tenant_onboarding',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$connection = $this->resolveSelectedProviderConnection($tenant);
|
$connection = $this->resolveSelectedProviderConnection($tenant);
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
@ -831,18 +890,9 @@ public function startVerification(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = app(ProviderOperationStartGate::class)->start(
|
$result = app(StartVerification::class)->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $connection,
|
connection: $connection,
|
||||||
operationType: 'provider.connection.check',
|
|
||||||
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void {
|
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
providerConnectionId: (int) $connection->getKey(),
|
|
||||||
operationRun: $run,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $user,
|
initiator: $user,
|
||||||
extraContext: [
|
extraContext: [
|
||||||
'wizard' => [
|
'wizard' => [
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages;
|
use App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -136,12 +137,35 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Verification report')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('verification_report')
|
||||||
|
->label('')
|
||||||
|
->view('filament.components.verification-report-viewer')
|
||||||
|
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Context')
|
Section::make('Context')
|
||||||
->schema([
|
->schema([
|
||||||
ViewEntry::make('context')
|
ViewEntry::make('context')
|
||||||
->label('')
|
->label('')
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
->state(fn (OperationRun $record): array => $record->context ?? [])
|
->state(function (OperationRun $record): array {
|
||||||
|
$context = $record->context ?? [];
|
||||||
|
$context = is_array($context) ? $context : [];
|
||||||
|
|
||||||
|
if (array_key_exists('verification_report', $context)) {
|
||||||
|
$context['verification_report'] = [
|
||||||
|
'redacted' => true,
|
||||||
|
'note' => 'Rendered in the Verification report section.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
})
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -15,6 +14,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -175,29 +175,22 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$initiator = $user;
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $verification->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'provider.connection.check',
|
initiator: $user,
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
||||||
use App\Jobs\ProviderInventorySyncJob;
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -14,6 +13,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -167,7 +167,7 @@ protected function getHeaderActions(): array
|
|||||||
&& $user->canAccessTenant($tenant)
|
&& $user->canAccessTenant($tenant)
|
||||||
&& $record->status !== 'disabled';
|
&& $record->status !== 'disabled';
|
||||||
})
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -185,18 +185,9 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $verification->providerConnectionCheck(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
connection: $record,
|
connection: $record,
|
||||||
operationType: 'provider.connection.check',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
44
app/Filament/Support/VerificationReportViewer.php
Normal file
44
app/Filament/Support/VerificationReportViewer.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Verification\VerificationReportSanitizer;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
|
|
||||||
|
final class VerificationReportViewer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function report(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$report = $context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||||
|
|
||||||
|
if (! VerificationReportSchema::isValidReport($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shouldRenderForRun(OperationRun $run): bool
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
|
if (array_key_exists('verification_report', $context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,11 +7,14 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\Contracts\HealthResult;
|
use App\Services\Providers\Contracts\HealthResult;
|
||||||
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -83,17 +86,64 @@ public function handle(
|
|||||||
|
|
||||||
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
|
||||||
|
|
||||||
|
$report = VerificationReportWriter::write(
|
||||||
|
run: $this->operationRun,
|
||||||
|
checks: [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => $result->healthy ? 'pass' : 'fail',
|
||||||
|
'severity' => $result->healthy ? 'info' : 'critical',
|
||||||
|
'blocking' => ! $result->healthy,
|
||||||
|
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
|
||||||
|
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
|
||||||
|
'evidence' => array_values(array_filter([
|
||||||
|
[
|
||||||
|
'kind' => 'provider_connection_id',
|
||||||
|
'value' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'kind' => 'entra_tenant_id',
|
||||||
|
'value' => (string) $connection->entra_tenant_id,
|
||||||
|
],
|
||||||
|
is_numeric($result->meta['http_status'] ?? null) ? [
|
||||||
|
'kind' => 'http_status',
|
||||||
|
'value' => (int) $result->meta['http_status'],
|
||||||
|
] : null,
|
||||||
|
is_string($result->meta['organization_id'] ?? null) ? [
|
||||||
|
'kind' => 'organization_id',
|
||||||
|
'value' => (string) $result->meta['organization_id'],
|
||||||
|
] : null,
|
||||||
|
])),
|
||||||
|
'next_steps' => $result->healthy
|
||||||
|
? []
|
||||||
|
: [[
|
||||||
|
'label' => 'Review provider connection',
|
||||||
|
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
|
||||||
|
'record' => (int) $connection->getKey(),
|
||||||
|
], tenant: $tenant),
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
identity: [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if ($result->healthy) {
|
if ($result->healthy) {
|
||||||
$runs->updateRun(
|
$run = $runs->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$runs->updateRun(
|
$run = $runs->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
@ -103,6 +153,8 @@ public function handle(
|
|||||||
'message' => $result->message ?? 'Health check failed.',
|
'message' => $result->message ?? 'Health check failed.',
|
||||||
]],
|
]],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->logVerificationCompletion($tenant, $user, $run, $report);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
|
||||||
@ -145,4 +197,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
|||||||
'last_error_message' => $result->healthy ? null : $result->message,
|
'last_error_message' => $result->healthy ? null : $result->message,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
|
||||||
|
{
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
|
if (! $workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $report['summary']['counts'] ?? [];
|
||||||
|
$counts = is_array($counts) ? $counts : [];
|
||||||
|
|
||||||
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::VerificationCompleted->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'counts' => $counts,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $run->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
app/Services/Verification/StartVerification.php
Normal file
57
app/Services/Verification/StartVerification.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Verification;
|
||||||
|
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class StartVerification
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProviderOperationStartGate $providers,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (or dedupe) a provider-connection verification run.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $extraContext
|
||||||
|
*/
|
||||||
|
public function providerConnectionCheck(
|
||||||
|
Tenant $tenant,
|
||||||
|
ProviderConnection $connection,
|
||||||
|
User $initiator,
|
||||||
|
array $extraContext = [],
|
||||||
|
): ProviderOperationStartResult {
|
||||||
|
if (! $initiator->canAccessTenant($tenant)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
|
||||||
|
return $this->providers->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
|
||||||
|
ProviderConnectionHealthCheckJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: $extraContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,4 +27,6 @@ enum AuditActionId: string
|
|||||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||||
|
|
||||||
|
case VerificationCompleted = 'verification.completed';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,9 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||||
|
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||||
|
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
||||||
|
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -28,4 +28,7 @@ enum BadgeDomain: string
|
|||||||
case RestoreResultStatus = 'restore_result_status';
|
case RestoreResultStatus = 'restore_result_status';
|
||||||
case ProviderConnectionStatus = 'provider_connection.status';
|
case ProviderConnectionStatus = 'provider_connection.status';
|
||||||
case ProviderConnectionHealth = 'provider_connection.health';
|
case ProviderConnectionHealth = 'provider_connection.health';
|
||||||
|
case VerificationCheckStatus = 'verification_check_status';
|
||||||
|
case VerificationCheckSeverity = 'verification_check_severity';
|
||||||
|
case VerificationReportOverall = 'verification_report_overall';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Verification\VerificationCheckSeverity;
|
||||||
|
|
||||||
|
final class VerificationCheckSeverityBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
|
||||||
|
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
|
||||||
|
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
|
||||||
|
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Support/Badges/Domains/VerificationCheckStatusBadge.php
Normal file
25
app/Support/Badges/Domains/VerificationCheckStatusBadge.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Verification\VerificationCheckStatus;
|
||||||
|
|
||||||
|
final class VerificationCheckStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
|
||||||
|
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
|
||||||
|
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
|
|
||||||
|
final class VerificationReportOverallBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||||
|
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Support/Verification/VerificationCheckSeverity.php
Normal file
20
app/Support/Verification/VerificationCheckSeverity.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
enum VerificationCheckSeverity: string
|
||||||
|
{
|
||||||
|
case Info = 'info';
|
||||||
|
case Low = 'low';
|
||||||
|
case Medium = 'medium';
|
||||||
|
case High = 'high';
|
||||||
|
case Critical = 'critical';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Support/Verification/VerificationCheckStatus.php
Normal file
20
app/Support/Verification/VerificationCheckStatus.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
enum VerificationCheckStatus: string
|
||||||
|
{
|
||||||
|
case Pass = 'pass';
|
||||||
|
case Fail = 'fail';
|
||||||
|
case Warn = 'warn';
|
||||||
|
case Skip = 'skip';
|
||||||
|
case Running = 'running';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Support/Verification/VerificationReportOverall.php
Normal file
19
app/Support/Verification/VerificationReportOverall.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
enum VerificationReportOverall: string
|
||||||
|
{
|
||||||
|
case Ready = 'ready';
|
||||||
|
case NeedsAttention = 'needs_attention';
|
||||||
|
case Blocked = 'blocked';
|
||||||
|
case Running = 'running';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
358
app/Support/Verification/VerificationReportSanitizer.php
Normal file
358
app/Support/Verification/VerificationReportSanitizer.php
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
final class VerificationReportSanitizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
private const FORBIDDEN_KEY_SUBSTRINGS = [
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'client_secret',
|
||||||
|
'authorization',
|
||||||
|
'password',
|
||||||
|
'cookie',
|
||||||
|
'set-cookie',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function sanitizeReport(array $report): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
|
||||||
|
if ($schemaVersion !== null) {
|
||||||
|
$sanitized['schema_version'] = $schemaVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
|
||||||
|
if ($flow !== null) {
|
||||||
|
$sanitized['flow'] = $flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
|
||||||
|
if ($generatedAt !== null) {
|
||||||
|
$sanitized['generated_at'] = $generatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($report['identity'] ?? null)) {
|
||||||
|
$identity = self::sanitizeIdentity((array) $report['identity']);
|
||||||
|
|
||||||
|
if ($identity !== []) {
|
||||||
|
$sanitized['identity'] = $identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
|
||||||
|
$summary = self::sanitizeSummary($summary);
|
||||||
|
|
||||||
|
if ($summary !== null) {
|
||||||
|
$sanitized['summary'] = $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
|
||||||
|
$checks = self::sanitizeChecks($checks);
|
||||||
|
|
||||||
|
if ($checks !== null) {
|
||||||
|
$sanitized['checks'] = $checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $identity
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
private static function sanitizeIdentity(array $identity): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($identity as $key => $value) {
|
||||||
|
if (! is_string($key) || trim($key) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::containsForbiddenKeySubstring($key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value)) {
|
||||||
|
$sanitized[$key] = $value;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = self::sanitizeValueString($value);
|
||||||
|
|
||||||
|
if ($value !== null) {
|
||||||
|
$sanitized[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summary
|
||||||
|
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
|
||||||
|
*/
|
||||||
|
private static function sanitizeSummary(array $summary): ?array
|
||||||
|
{
|
||||||
|
$overall = $summary['overall'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
|
||||||
|
|
||||||
|
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||||
|
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overall' => $overall,
|
||||||
|
'counts' => [
|
||||||
|
'total' => $counts['total'],
|
||||||
|
'pass' => $counts['pass'],
|
||||||
|
'fail' => $counts['fail'],
|
||||||
|
'warn' => $counts['warn'],
|
||||||
|
'skip' => $counts['skip'],
|
||||||
|
'running' => $counts['running'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $checks
|
||||||
|
* @return array<int, array<string, mixed>>|null
|
||||||
|
*/
|
||||||
|
private static function sanitizeChecks(array $checks): ?array
|
||||||
|
{
|
||||||
|
if ($checks === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
if (! is_array($check)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
|
||||||
|
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
|
||||||
|
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
|
||||||
|
|
||||||
|
if ($key === null || $title === null || $reasonCode === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $check['status'] ?? null;
|
||||||
|
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$severity = $check['severity'] ?? null;
|
||||||
|
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messageRaw = $check['message'] ?? null;
|
||||||
|
if (! is_string($messageRaw) || trim($messageRaw) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||||
|
|
||||||
|
$sanitized[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'title' => $title,
|
||||||
|
'status' => $status,
|
||||||
|
'severity' => $severity,
|
||||||
|
'blocking' => $blocking,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => self::sanitizeMessage($messageRaw),
|
||||||
|
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
|
||||||
|
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $evidence
|
||||||
|
* @return array<int, array{kind: string, value: int|string}>
|
||||||
|
*/
|
||||||
|
private static function sanitizeEvidence(array $evidence): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($evidence as $pointer) {
|
||||||
|
if (! is_array($pointer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kind = $pointer['kind'] ?? null;
|
||||||
|
if (! is_string($kind) || trim($kind) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::containsForbiddenKeySubstring($kind)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $pointer['value'] ?? null;
|
||||||
|
|
||||||
|
if (is_int($value)) {
|
||||||
|
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitizedValue = self::sanitizeValueString($value);
|
||||||
|
|
||||||
|
if ($sanitizedValue === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $nextSteps
|
||||||
|
* @return array<int, array{label: string, url: string}>
|
||||||
|
*/
|
||||||
|
private static function sanitizeNextSteps(array $nextSteps): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($nextSteps as $step) {
|
||||||
|
if (! is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
|
||||||
|
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
|
||||||
|
|
||||||
|
if ($label === null || $url === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitized[] = [
|
||||||
|
'label' => $label,
|
||||||
|
'url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitizeMessage(mixed $message): string
|
||||||
|
{
|
||||||
|
if (! is_string($message)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||||
|
|
||||||
|
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||||
|
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
|
||||||
|
|
||||||
|
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
|
||||||
|
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
|
||||||
|
|
||||||
|
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||||
|
|
||||||
|
$message = str_ireplace(
|
||||||
|
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
|
||||||
|
'[REDACTED]',
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
|
||||||
|
$message = trim($message);
|
||||||
|
|
||||||
|
return $message === '' ? '—' : substr($message, 0, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::containsForbiddenKeySubstring($value)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($value, 0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitizeValueString(string $value): ?string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($value) > 512) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lower = strtolower($value);
|
||||||
|
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||||
|
if (str_contains($lower, $needle)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function containsForbiddenKeySubstring(string $value): bool
|
||||||
|
{
|
||||||
|
$lower = strtolower($value);
|
||||||
|
|
||||||
|
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
|
||||||
|
if (str_contains($lower, $needle)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
app/Support/Verification/VerificationReportSchema.php
Normal file
235
app/Support/Verification/VerificationReportSchema.php
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final class VerificationReportSchema
|
||||||
|
{
|
||||||
|
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function normalizeReport(mixed $report): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isValidReport($report)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
public static function isValidReport(array $report): bool
|
||||||
|
{
|
||||||
|
$schemaVersion = self::schemaVersion($report);
|
||||||
|
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isNonEmptyString($report['flow'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $report['summary'] ?? null;
|
||||||
|
if (! is_array($summary)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$overall = $summary['overall'] ?? null;
|
||||||
|
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = $summary['counts'] ?? null;
|
||||||
|
if (! is_array($counts)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
||||||
|
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$checks = $report['checks'] ?? null;
|
||||||
|
if (! is_array($checks)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
if (! is_array($check) || ! self::isValidCheckResult($check)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $report
|
||||||
|
*/
|
||||||
|
public static function schemaVersion(array $report): ?string
|
||||||
|
{
|
||||||
|
$candidate = $report['schema_version'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($candidate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = trim($candidate);
|
||||||
|
|
||||||
|
if ($candidate === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isSupportedSchemaVersion(string $schemaVersion): bool
|
||||||
|
{
|
||||||
|
$parts = explode('.', $schemaVersion, 3);
|
||||||
|
|
||||||
|
if (count($parts) !== 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$major = (int) $parts[0];
|
||||||
|
|
||||||
|
return $major === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $check
|
||||||
|
*/
|
||||||
|
private static function isValidCheckResult(array $check): bool
|
||||||
|
{
|
||||||
|
if (! self::isNonEmptyString($check['key'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isNonEmptyString($check['title'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $check['status'] ?? null;
|
||||||
|
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$severity = $check['severity'] ?? null;
|
||||||
|
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_bool($check['blocking'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isNonEmptyString($check['message'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence = $check['evidence'] ?? null;
|
||||||
|
if (! is_array($evidence)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($evidence as $pointer) {
|
||||||
|
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextSteps = $check['next_steps'] ?? null;
|
||||||
|
if (! is_array($nextSteps)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($nextSteps as $step) {
|
||||||
|
if (! is_array($step) || ! self::isValidNextStep($step)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $pointer
|
||||||
|
*/
|
||||||
|
private static function isValidEvidencePointer(array $pointer): bool
|
||||||
|
{
|
||||||
|
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $pointer['value'] ?? null;
|
||||||
|
|
||||||
|
return is_int($value) || self::isNonEmptyString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $step
|
||||||
|
*/
|
||||||
|
private static function isValidNextStep(array $step): bool
|
||||||
|
{
|
||||||
|
if (! self::isNonEmptyString($step['label'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isNonEmptyString($step['url'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isNonEmptyString(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_string($value) && trim($value) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isNonNegativeInt(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_int($value) && $value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isIsoDateTimeString(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (! self::isNonEmptyString($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new DateTimeImmutable((string) $value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
app/Support/Verification/VerificationReportWriter.php
Normal file
343
app/Support/Verification/VerificationReportWriter.php
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Verification;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
|
||||||
|
final class VerificationReportWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Baseline reason code taxonomy (v1).
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
private const array BASELINE_REASON_CODES = [
|
||||||
|
'ok',
|
||||||
|
'not_applicable',
|
||||||
|
'missing_configuration',
|
||||||
|
'permission_denied',
|
||||||
|
'authentication_failed',
|
||||||
|
'throttled',
|
||||||
|
'dependency_unreachable',
|
||||||
|
'invalid_state',
|
||||||
|
'unknown_error',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $checks
|
||||||
|
* @param array<string, mixed> $identity
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function write(OperationRun $run, array $checks, array $identity = []): array
|
||||||
|
{
|
||||||
|
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
|
||||||
|
|
||||||
|
$report = self::build($flow, $checks, $identity);
|
||||||
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||||
|
|
||||||
|
if (! VerificationReportSchema::isValidReport($report)) {
|
||||||
|
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$context['verification_report'] = $report;
|
||||||
|
|
||||||
|
$run->update(['context' => $context]);
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $checks
|
||||||
|
* @param array<string, mixed> $identity
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function build(string $flow, array $checks, array $identity = []): array
|
||||||
|
{
|
||||||
|
$flow = trim($flow);
|
||||||
|
$flow = $flow !== '' ? $flow : 'unknown';
|
||||||
|
|
||||||
|
$normalizedChecks = [];
|
||||||
|
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
if (! is_array($check)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedChecks[] = self::normalizeCheckResult($check);
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = self::deriveCounts($normalizedChecks);
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||||
|
'flow' => $flow,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'summary' => [
|
||||||
|
'overall' => self::deriveOverall($normalizedChecks, $counts),
|
||||||
|
'counts' => $counts,
|
||||||
|
],
|
||||||
|
'checks' => $normalizedChecks,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($identity !== []) {
|
||||||
|
$report['identity'] = $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function buildFallbackReport(string $flow): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||||
|
'flow' => $flow !== '' ? $flow : 'unknown',
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'summary' => [
|
||||||
|
'overall' => VerificationReportOverall::NeedsAttention->value,
|
||||||
|
'counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'pass' => 0,
|
||||||
|
'fail' => 0,
|
||||||
|
'warn' => 0,
|
||||||
|
'skip' => 0,
|
||||||
|
'running' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'checks' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $check
|
||||||
|
* @return array{
|
||||||
|
* key: string,
|
||||||
|
* title: string,
|
||||||
|
* status: string,
|
||||||
|
* severity: string,
|
||||||
|
* blocking: bool,
|
||||||
|
* reason_code: string,
|
||||||
|
* message: string,
|
||||||
|
* evidence: array<int, array{kind: string, value: int|string}>,
|
||||||
|
* next_steps: array<int, array{label: string, url: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function normalizeCheckResult(array $check): array
|
||||||
|
{
|
||||||
|
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
|
||||||
|
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => $key,
|
||||||
|
'title' => $title,
|
||||||
|
'status' => self::normalizeCheckStatus($check['status'] ?? null),
|
||||||
|
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
|
||||||
|
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
|
||||||
|
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
|
||||||
|
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
|
||||||
|
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
|
||||||
|
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeCheckStatus(mixed $status): string
|
||||||
|
{
|
||||||
|
if (! is_string($status)) {
|
||||||
|
return VerificationCheckStatus::Fail->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = strtolower(trim($status));
|
||||||
|
|
||||||
|
return in_array($status, VerificationCheckStatus::values(), true)
|
||||||
|
? $status
|
||||||
|
: VerificationCheckStatus::Fail->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeCheckSeverity(mixed $severity): string
|
||||||
|
{
|
||||||
|
if (! is_string($severity)) {
|
||||||
|
return VerificationCheckSeverity::Info->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$severity = strtolower(trim($severity));
|
||||||
|
|
||||||
|
return in_array($severity, VerificationCheckSeverity::values(), true)
|
||||||
|
? $severity
|
||||||
|
: VerificationCheckSeverity::Info->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeReasonCode(mixed $reasonCode): string
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode)) {
|
||||||
|
return 'unknown_error';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = strtolower(trim($reasonCode));
|
||||||
|
|
||||||
|
if ($reasonCode === '') {
|
||||||
|
return 'unknown_error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($reasonCode, 'ext.')) {
|
||||||
|
return $reasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = match ($reasonCode) {
|
||||||
|
'graph_throttled' => 'throttled',
|
||||||
|
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
|
||||||
|
'provider_auth_failed' => 'authentication_failed',
|
||||||
|
'validation_error', 'conflict_detected' => 'invalid_state',
|
||||||
|
'unknown' => 'unknown_error',
|
||||||
|
default => $reasonCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{kind: string, value: int|string}>
|
||||||
|
*/
|
||||||
|
private static function normalizeEvidence(mixed $evidence): array
|
||||||
|
{
|
||||||
|
if (! is_array($evidence)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($evidence as $pointer) {
|
||||||
|
if (! is_array($pointer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
|
||||||
|
$value = $pointer['value'] ?? null;
|
||||||
|
|
||||||
|
if ($kind === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_int($value) && ! is_string($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = [
|
||||||
|
'kind' => $kind,
|
||||||
|
'value' => is_int($value) ? $value : trim($value),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{label: string, url: string}>
|
||||||
|
*/
|
||||||
|
private static function normalizeNextSteps(mixed $steps): array
|
||||||
|
{
|
||||||
|
if (! is_array($steps)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($steps as $step) {
|
||||||
|
if (! is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
|
||||||
|
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
|
||||||
|
|
||||||
|
if ($label === null || $url === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = [
|
||||||
|
'label' => $label,
|
||||||
|
'url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{status: string, blocking: bool}> $checks
|
||||||
|
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
|
||||||
|
*/
|
||||||
|
private static function deriveCounts(array $checks): array
|
||||||
|
{
|
||||||
|
$counts = [
|
||||||
|
'total' => count($checks),
|
||||||
|
'pass' => 0,
|
||||||
|
'fail' => 0,
|
||||||
|
'warn' => 0,
|
||||||
|
'skip' => 0,
|
||||||
|
'running' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
$status = $check['status'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($status) || ! array_key_exists($status, $counts)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts[$status] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{status: string, blocking: bool}> $checks
|
||||||
|
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
|
||||||
|
*/
|
||||||
|
private static function deriveOverall(array $checks, array $counts): string
|
||||||
|
{
|
||||||
|
if (($counts['running'] ?? 0) > 0) {
|
||||||
|
return VerificationReportOverall::Running->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($counts['total'] ?? 0) === 0) {
|
||||||
|
return VerificationReportOverall::NeedsAttention->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
|
||||||
|
return VerificationReportOverall::Blocked->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
|
||||||
|
return VerificationReportOverall::NeedsAttention->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationReportOverall::Ready->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
@php
|
||||||
|
$report = isset($getState) ? $getState() : ($report ?? null);
|
||||||
|
$report = is_array($report) ? $report : null;
|
||||||
|
|
||||||
|
$summary = $report['summary'] ?? null;
|
||||||
|
$summary = is_array($summary) ? $summary : null;
|
||||||
|
|
||||||
|
$counts = $summary['counts'] ?? null;
|
||||||
|
$counts = is_array($counts) ? $counts : [];
|
||||||
|
|
||||||
|
$checks = $report['checks'] ?? null;
|
||||||
|
$checks = is_array($checks) ? $checks : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
@if ($report === null || $summary === null)
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">
|
||||||
|
Verification report unavailable
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
This run doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
@php
|
||||||
|
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
|
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||||
|
$summary['overall'] ?? null,
|
||||||
|
);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||||
|
{{ $overallSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
{{ (int) ($counts['total'] ?? 0) }} total
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="warning">
|
||||||
|
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="info">
|
||||||
|
{{ (int) ($counts['running'] ?? 0) }} running
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($checks === [])
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||||
|
No checks found in this report. Start verification again to generate a fresh report.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($checks as $check)
|
||||||
|
@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 : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<summary class="flex cursor-pointer items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ $title }}
|
||||||
|
</div>
|
||||||
|
@if ($message)
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||||
|
{{ $severitySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||||
|
{{ $statusSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
@if ($evidence !== [] || $nextSteps !== [])
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
@if ($evidence !== [])
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Evidence
|
||||||
|
</div>
|
||||||
|
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
@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)))
|
||||||
|
<li>
|
||||||
|
<span class="font-medium">{{ $kind }}:</span>
|
||||||
|
<span>{{ is_int($value) ? $value : $value }}</span>
|
||||||
|
</li>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($nextSteps !== [])
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Next steps
|
||||||
|
</div>
|
||||||
|
<ul class="mt-2 space-y-1 text-sm">
|
||||||
|
@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 !== '')
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="{{ $url }}"
|
||||||
|
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||||
|
@if ($isExternal)
|
||||||
|
target="_blank" rel="noreferrer"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
{{ $label }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</details>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
34
specs/074-verification-checklist/checklists/requirements.md
Normal file
34
specs/074-verification-checklist/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Verification Checklist Framework (Enterprise-Ready)
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-03
|
||||||
|
**Feature**: [specs/074-verification-checklist/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
|
||||||
|
|
||||||
|
- Validation pass (2026-02-03): Spec avoids framework specifics and focuses on contract + UX outcomes. Next step is planning to translate these requirements into a minimal set of deliverables (report schema, viewer, authorization semantics, audit events, and adoption points).
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"flow": "provider.connection.check",
|
||||||
|
"generated_at": "2026-02-03T22:00:00Z",
|
||||||
|
"identity": {
|
||||||
|
"provider_connection_id": 123
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"overall": "blocked",
|
||||||
|
"counts": {
|
||||||
|
"total": 2,
|
||||||
|
"pass": 1,
|
||||||
|
"fail": 1,
|
||||||
|
"warn": 0,
|
||||||
|
"skip": 0,
|
||||||
|
"running": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"key": "provider_connection.token_acquisition",
|
||||||
|
"title": "Token acquisition works",
|
||||||
|
"status": "fail",
|
||||||
|
"severity": "high",
|
||||||
|
"blocking": true,
|
||||||
|
"reason_code": "authentication_failed",
|
||||||
|
"message": "The app cannot acquire a token with the configured credentials.",
|
||||||
|
"evidence": [
|
||||||
|
{ "kind": "provider_connection_id", "value": 123 }
|
||||||
|
],
|
||||||
|
"next_steps": [
|
||||||
|
{ "label": "Review connection credentials", "url": "/admin/provider-connections/123/edit" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider_connection.permissions",
|
||||||
|
"title": "Required permissions are granted",
|
||||||
|
"status": "pass",
|
||||||
|
"severity": "info",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"message": "The configured app permissions meet the required baseline.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"flow": "provider.connection.check",
|
||||||
|
"generated_at": "2026-02-03T22:00:00Z",
|
||||||
|
"summary": {
|
||||||
|
"overall": "ready",
|
||||||
|
"counts": {
|
||||||
|
"total": 1,
|
||||||
|
"pass": 1,
|
||||||
|
"fail": 0,
|
||||||
|
"warn": 0,
|
||||||
|
"skip": 0,
|
||||||
|
"running": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"key": "provider_connection.health",
|
||||||
|
"title": "Provider connection is healthy",
|
||||||
|
"status": "pass",
|
||||||
|
"severity": "info",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"message": "The provider connection passed all required health checks.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"flow": "provider.connection.check",
|
||||||
|
"generated_at": "2026-02-03T22:00:00Z",
|
||||||
|
"summary": {
|
||||||
|
"overall": "running",
|
||||||
|
"counts": {
|
||||||
|
"total": 3,
|
||||||
|
"pass": 1,
|
||||||
|
"fail": 0,
|
||||||
|
"warn": 0,
|
||||||
|
"skip": 0,
|
||||||
|
"running": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"key": "provider_connection.token_acquisition",
|
||||||
|
"title": "Token acquisition works",
|
||||||
|
"status": "running",
|
||||||
|
"severity": "info",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"message": "Check is currently running.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider_connection.permissions",
|
||||||
|
"title": "Required permissions are granted",
|
||||||
|
"status": "running",
|
||||||
|
"severity": "info",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"message": "Check is currently running.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider_connection.health",
|
||||||
|
"title": "Provider connection is healthy",
|
||||||
|
"status": "pass",
|
||||||
|
"severity": "info",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"message": "The provider connection passed all required health checks.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"flow": "provider.connection.check",
|
||||||
|
"generated_at": "2026-02-03T22:00:00Z",
|
||||||
|
"summary": {
|
||||||
|
"overall": "needs_attention",
|
||||||
|
"counts": {
|
||||||
|
"total": 2,
|
||||||
|
"pass": 1,
|
||||||
|
"fail": 0,
|
||||||
|
"warn": 1,
|
||||||
|
"skip": 0,
|
||||||
|
"running": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"key": "provider_connection.optional_metadata",
|
||||||
|
"title": "Optional metadata is present",
|
||||||
|
"status": "warn",
|
||||||
|
"severity": "medium",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "missing_configuration",
|
||||||
|
"message": "Some optional metadata is missing; this may reduce diagnostics quality.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": [
|
||||||
|
{ "label": "Open provider connection settings", "url": "/admin/provider-connections" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider_connection.health",
|
||||||
|
"title": "Provider connection is healthy",
|
||||||
|
"status": "pass",
|
||||||
|
"severity": "info",
|
||||||
|
"blocking": false,
|
||||||
|
"reason_code": "ok",
|
||||||
|
"message": "The provider connection passed all required health checks.",
|
||||||
|
"evidence": [],
|
||||||
|
"next_steps": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
specs/074-verification-checklist/contracts/reason-codes.md
Normal file
26
specs/074-verification-checklist/contracts/reason-codes.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Reason Codes (074)
|
||||||
|
|
||||||
|
This file defines the baseline `reason_code` taxonomy for verification check results.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Reason codes are **stable** and **machine-readable**.
|
||||||
|
- New codes must be appended (avoid renames) to keep support and automation stable.
|
||||||
|
- Flow/check-specific codes must use the reserved namespace: `ext.*`.
|
||||||
|
|
||||||
|
## Baseline Codes (v1)
|
||||||
|
|
||||||
|
- `ok` — Check passed.
|
||||||
|
- `not_applicable` — Check skipped because it doesn’t apply to this identity/scope.
|
||||||
|
- `missing_configuration` — Required config is absent.
|
||||||
|
- `permission_denied` — Insufficient permissions / consent missing.
|
||||||
|
- `authentication_failed` — Token acquisition or auth precondition failed.
|
||||||
|
- `throttled` — Remote dependency throttled (e.g., 429/503) and check could not complete.
|
||||||
|
- `dependency_unreachable` — Remote dependency unavailable.
|
||||||
|
- `invalid_state` — Local model state conflicts with required preconditions.
|
||||||
|
- `unknown_error` — Failure could not be classified.
|
||||||
|
|
||||||
|
## Reserved Extension Namespace
|
||||||
|
|
||||||
|
- `ext.<flow>.<detail>` — Flow-specific extensions.
|
||||||
|
- Example: `ext.managed_tenant_onboarding.role_mapping_missing`
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://tenantpilot.local/contracts/verification-report.schema.json",
|
||||||
|
"title": "VerificationReport",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"flow",
|
||||||
|
"generated_at",
|
||||||
|
"summary",
|
||||||
|
"checks"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version of the verification report schema (SemVer)."
|
||||||
|
},
|
||||||
|
"flow": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Scope identifiers for what is being verified.",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
specs/074-verification-checklist/data-model.md
Normal file
61
specs/074-verification-checklist/data-model.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Data Model: Verification Checklist Framework (074)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature introduces a *versioned verification report document* attached to an existing `OperationRun`.
|
||||||
|
No new database tables are required for v1.
|
||||||
|
|
||||||
|
## Existing Entities Used
|
||||||
|
|
||||||
|
### OperationRun (`operation_runs`)
|
||||||
|
|
||||||
|
Selected fields:
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `user_id`
|
||||||
|
- `type` (used as the verification flow identifier)
|
||||||
|
- `status` (`queued` | `running` | `completed`)
|
||||||
|
- `outcome` (`pending` | `succeeded` | `failed`)
|
||||||
|
- `summary_counts` (JSONB)
|
||||||
|
- `failure_summary` (JSONB)
|
||||||
|
- `context` (JSONB)
|
||||||
|
- `started_at`, `completed_at`
|
||||||
|
|
||||||
|
Idempotency:
|
||||||
|
- DB-enforced dedupe for active runs via partial unique index on `(tenant_id, run_identity_hash)` where `status IN ('queued','running')`.
|
||||||
|
|
||||||
|
## New Logical Data (stored inside OperationRun context)
|
||||||
|
|
||||||
|
### VerificationReport (`operation_runs.context.verification_report`)
|
||||||
|
|
||||||
|
- Stored as JSON in `context` under `verification_report`.
|
||||||
|
- Versioned by `schema_version`.
|
||||||
|
- Rendered DB-only (no external calls during view).
|
||||||
|
|
||||||
|
High-level shape (see `contracts/verification-report.schema.json` for the canonical contract):
|
||||||
|
- `schema_version`
|
||||||
|
- `flow` (identifier; for v1 this can align with `operation_runs.type`)
|
||||||
|
- `identity` (scope identifiers such as `tenant_id`, `provider_connection_id`, etc.)
|
||||||
|
- `generated_at`
|
||||||
|
- `summary` (counts, overall state)
|
||||||
|
- `checks[]` (check results)
|
||||||
|
|
||||||
|
### CheckResult (within `checks[]`)
|
||||||
|
|
||||||
|
- `key`, `title`
|
||||||
|
- `status`: `pass|fail|warn|skip|running`
|
||||||
|
- `severity`: `info|low|medium|high|critical`
|
||||||
|
- `blocking`: boolean
|
||||||
|
- `reason_code`
|
||||||
|
- `message`
|
||||||
|
- `evidence[]`: safe pointers only
|
||||||
|
- `next_steps[]`: links only in v1
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
Verification start and completion are recorded in `audit_logs` using stable `action` identifiers (via `App\Support\Audit\AuditActionId`). Metadata is minimal and sanitized.
|
||||||
|
|
||||||
|
## Notes / Constraints
|
||||||
|
|
||||||
|
- Viewer must be DB-only: rendering the report must not dispatch jobs or perform HTTP.
|
||||||
|
- Evidence must be redacted/safe: no secrets/tokens/payload dumps in stored or rendered report.
|
||||||
127
specs/074-verification-checklist/plan.md
Normal file
127
specs/074-verification-checklist/plan.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Implementation Plan: Verification Checklist Framework (Enterprise-Ready)
|
||||||
|
|
||||||
|
**Branch**: `074-verification-checklist` | **Date**: 2026-02-03 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/074-verification-checklist/spec.md`
|
||||||
|
|
||||||
|
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Introduce a versioned “verification report” contract that can be attached to an existing `OperationRun` and rendered consistently across multiple flows.
|
||||||
|
- Provide a reusable, DB-only report viewer (no outbound calls during render/hydration/poll) that presents summary + per-check statuses + safe evidence pointers + navigation-only next steps.
|
||||||
|
- Enforce enterprise semantics: stable reason codes, strict evidence redaction, deterministic active-run dedupe, and capability-first authorization aligned with RBAC-UX (non-members 404; members missing start capability 403).
|
||||||
|
- Emit audit events for verification start + completion using stable action identifiers with redacted metadata.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4
|
||||||
|
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`)
|
||||||
|
**Testing**: Pest (PHPUnit)
|
||||||
|
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
|
||||||
|
**Project Type**: web
|
||||||
|
**Performance Goals**: Verification viewer renders fast from DB-only JSON (typical report ≤ 50 checks)
|
||||||
|
**Constraints**: Viewer is read-only and must not trigger any outbound HTTP or job dispatch; evidence must not contain secrets/tokens/payloads; new status-like badges must use centralized BADGE-001 mapping.
|
||||||
|
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding + provider ops + future readiness flows
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first, snapshots-second: PASS (this feature is run/report UX; no inventory semantics changed).
|
||||||
|
- Read/write separation: PASS (viewer is read-only; start surfaces enqueue-only and already follow `OperationRun` patterns).
|
||||||
|
- Graph contract path: PASS (viewer performs no Graph calls; verification execution remains in queued jobs that already follow provider gateway patterns).
|
||||||
|
- Deterministic capabilities: PASS (start/view gates reference the existing capability registry; no role-string checks).
|
||||||
|
- RBAC-UX: PASS (non-member tenant access is 404; member-but-missing-capability is 403; server-side gates enforce mutations/starts).
|
||||||
|
- Run observability: PASS (verification is represented as `OperationRun`; active-run dedupe enforced by the existing partial unique index on `(tenant_id, run_identity_hash)` for active statuses).
|
||||||
|
- Data minimization: PASS (report evidence constrained to safe pointers; audit metadata redacted; no secrets in stored report).
|
||||||
|
- Badge semantics (BADGE-001): PASS (plan includes adding a centralized badge domain for check statuses/severity; no ad-hoc UI mappings).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/074-verification-checklist/
|
||||||
|
├── 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/
|
||||||
|
|
||||||
|
config/
|
||||||
|
database/
|
||||||
|
resources/
|
||||||
|
routes/
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single Laravel web application with Filament admin panel. The framework is implemented as:
|
||||||
|
- contract + helpers under `app/Support/**`
|
||||||
|
- report writer invoked from queued jobs under `app/Jobs/**` / `app/Services/**`
|
||||||
|
- viewer UI as Filament schema components and Blade views under `app/Filament/**` and `resources/views/filament/**`
|
||||||
|
- authorization via existing capabilities/gates/policies
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations required for this feature.
|
||||||
|
|
||||||
|
## Phase 0 — Research (output: `research.md`)
|
||||||
|
|
||||||
|
See: [research.md](./research.md)
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
- Confirm the canonical storage location for the report (DB-only render) using existing `operation_runs.context` JSONB.
|
||||||
|
- Confirm active-run dedupe behavior and ensure it matches the spec’s “dedupe while active only” requirement.
|
||||||
|
- Confirm the correct approach for status-like UI badges in Filament (BADGE-001), so the viewer doesn’t introduce ad-hoc mappings.
|
||||||
|
- Confirm the existing audit logger + redaction utilities and define stable action IDs for verification completion.
|
||||||
|
|
||||||
|
## 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 schema: versioned JSON document (checks + counts + timestamps + next steps) stored in `operation_runs.context.verification_report`.
|
||||||
|
- Reason codes: baseline set + reserved `ext.*` namespace.
|
||||||
|
- Evidence redaction: strict sanitizer so reports never store or render secrets/tokens/payloads.
|
||||||
|
- Viewer: reusable Filament view entry / component that renders summary + per-check details without any outbound calls.
|
||||||
|
- Authorization: view allowed for tenant-scoped members; start requires capability; non-member access is deny-as-not-found.
|
||||||
|
- Auditing: start + completion events logged with minimal redacted metadata.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||||
|
|
||||||
|
- Contract: create a canonical report schema (JSON Schema + example) and a small baseline reason-code list.
|
||||||
|
- Writer: add a `VerificationReportWriter` (or equivalent) that normalizes check results, enforces redaction rules, and writes the report into `OperationRun->context`.
|
||||||
|
- Viewer: add a reusable Filament UI renderer (Blade view + helper) that displays summary, counts, statuses, and next steps (links-only).
|
||||||
|
- Integration points:
|
||||||
|
- Show the verification report section in Monitoring → Operations run detail when present.
|
||||||
|
- Embed the same viewer in onboarding and provider connection verification flows.
|
||||||
|
- RBAC & UX:
|
||||||
|
- Enforce “view vs start” split (view allowed for tenant members; start capability required).
|
||||||
|
- Preserve RBAC-UX semantics (non-members 404; members missing capability 403).
|
||||||
|
- Audit:
|
||||||
|
- Keep existing start audit event; add a completion audit event emitted when the verification run finalizes.
|
||||||
|
- Tests (Pest):
|
||||||
|
- Viewer is DB-only (Http::fake + render assertion).
|
||||||
|
- Evidence redaction rules (report contains no forbidden keys/values).
|
||||||
|
- Dedupe semantics reuse active run (leveraging the existing partial unique index behavior).
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS. Design artifacts keep verification viewing DB-only, align with run observability + dedupe, enforce RBAC-UX semantics, and centralize status badge mappings.
|
||||||
79
specs/074-verification-checklist/quickstart.md
Normal file
79
specs/074-verification-checklist/quickstart.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Quickstart: Verification Checklist Framework (074)
|
||||||
|
|
||||||
|
This quickstart explains how to *write* and *render* a verification report attached to an `OperationRun`.
|
||||||
|
|
||||||
|
## 1) Writing a report (queued job / service)
|
||||||
|
|
||||||
|
**Goal**: produce a `verification_report` JSON document and store it in `OperationRun->context`.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Generate reports inside queued execution (not in a Filament page render).
|
||||||
|
- Keep evidence pointer-only (IDs/masked/hashes), never raw payloads or tokens.
|
||||||
|
- Keep next steps navigation-only in v1.
|
||||||
|
|
||||||
|
Pseudo-code sketch:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
|
$context['verification_report'] = [
|
||||||
|
'schema_version' => '1.0',
|
||||||
|
'flow' => $run->type,
|
||||||
|
'generated_at' => now('UTC')->toIso8601String(),
|
||||||
|
'identity' => [
|
||||||
|
'tenant_id' => (int) $run->tenant_id,
|
||||||
|
'provider_connection_id' => (int) data_get($run->context, 'provider_connection_id', 0),
|
||||||
|
],
|
||||||
|
'summary' => [
|
||||||
|
'overall' => 'needs_attention',
|
||||||
|
'counts' => [
|
||||||
|
'total' => 5,
|
||||||
|
'pass' => 3,
|
||||||
|
'fail' => 2,
|
||||||
|
'warn' => 0,
|
||||||
|
'skip' => 0,
|
||||||
|
'running' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'checks' => [
|
||||||
|
[
|
||||||
|
'key' => 'provider_connection.token_acquisition',
|
||||||
|
'title' => 'Token acquisition works',
|
||||||
|
'status' => 'fail',
|
||||||
|
'severity' => 'high',
|
||||||
|
'blocking' => true,
|
||||||
|
'reason_code' => 'permission_denied',
|
||||||
|
'message' => 'The app cannot acquire a token with the configured credentials.',
|
||||||
|
'evidence' => [
|
||||||
|
['kind' => 'provider_connection_id', 'value' => (int) data_get($run->context, 'provider_connection_id')],
|
||||||
|
],
|
||||||
|
'next_steps' => [
|
||||||
|
['label' => 'Review connection credentials', 'url' => '/admin/...'],
|
||||||
|
['label' => 'Microsoft docs: app permissions', 'url' => 'https://learn.microsoft.com/...'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$run->update(['context' => $context]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Rendering the report (Filament, DB-only)
|
||||||
|
|
||||||
|
Recommended integration points:
|
||||||
|
- Monitoring → Operations: in the `OperationRun` view page, show a “Verification report” section when `context.verification_report` exists.
|
||||||
|
- Flow pages (e.g., onboarding wizard): embed the same viewer component using the run ID stored in wizard state.
|
||||||
|
|
||||||
|
**Hard requirement**: rendering must not trigger any outbound HTTP (no Graph calls, no jobs dispatched, no side effects).
|
||||||
|
|
||||||
|
## 3) Authorization split
|
||||||
|
|
||||||
|
- Viewing a report: allowed for tenant-scoped members.
|
||||||
|
- Starting verification: requires a specific capability.
|
||||||
|
- Non-members: deny-as-not-found (404) for tenant-scoped pages and actions.
|
||||||
|
|
||||||
|
## 4) Tests to add
|
||||||
|
|
||||||
|
- Viewer DB-only render test: `Http::fake()` + assert no requests during render.
|
||||||
|
- Evidence redaction test: report JSON contains none of `access_token`, `client_secret`, `Authorization`, bearer tokens, or raw payload dumps.
|
||||||
|
- Dedupe test: repeated starts while active reuse the same run.
|
||||||
86
specs/074-verification-checklist/research.md
Normal file
86
specs/074-verification-checklist/research.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Research: Verification Checklist Framework (074)
|
||||||
|
|
||||||
|
**Date**: 2026-02-03
|
||||||
|
**Phase**: Phase 0 (Foundational Research)
|
||||||
|
**Status**: Complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D-001 — Canonical storage location for verification reports
|
||||||
|
|
||||||
|
**Decision**: Store the verification report in `operation_runs.context.verification_report` (JSONB).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Monitoring pages must be DB-only at render time (constitution: Operations / Run Observability Standard).
|
||||||
|
- `OperationRun` is the canonical operational record; keeping the report attached avoids new tables/indexing for v1.
|
||||||
|
- The existing UI already renders `OperationRun.context` safely as JSON, so we can progressively enhance into a structured viewer.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Dedicated `verification_reports` table: rejected for v1 to keep adoption lightweight; can be introduced later if querying/indexing becomes necessary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-002 — Idempotency / dedupe mechanism
|
||||||
|
|
||||||
|
**Decision**: Use the existing `OperationRunService::ensureRunWithIdentity()` mechanism and the DB partial unique index on `(tenant_id, run_identity_hash)` for active statuses (`queued`, `running`).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- This repo already enforces active-run dedupe at the DB level via `operation_runs_active_unique`.
|
||||||
|
- Matches the clarified spec policy: dedupe only while a run is active; completed runs allow a new run.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Application-only locks/dedupe: rejected as non-race-safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-003 — Flow identifier and identity scope
|
||||||
|
|
||||||
|
**Decision**: Treat `OperationRun.type` as the primary flow identifier for the verification run, and keep additional flow details (wizard step, etc.) in `context`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Existing operations already key UX semantics (labels, polling, related links) off `OperationRun.type`.
|
||||||
|
- Dedupe identity hashing already includes `type`, making flow part of the dedupe boundary.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `flow_id` column: rejected for v1 (schema change not required).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-004 — Reason code taxonomy and extensions
|
||||||
|
|
||||||
|
**Decision**: Maintain a small baseline set of cross-cutting reason codes, and reserve `ext.*` for flow/check-specific extensions.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Prevents brittle UI parsing and enables future automation.
|
||||||
|
- Keeps room for flow-specific details without polluting the baseline vocabulary.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Free-form codes everywhere: rejected due to support/automation cost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-005 — Evidence policy (strict safe pointers)
|
||||||
|
|
||||||
|
**Decision**: Evidence fields in check results are *strictly* structured safe pointers only (IDs, masked strings, hashes). No payloads, tokens, claims, headers, or full error bodies.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Aligns with constitution data-minimization and safe logging rules.
|
||||||
|
- Avoids accidentally persisting secrets inside run context.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Storing raw error payloads: rejected for security and compliance risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-006 — UI semantics for statuses and badges
|
||||||
|
|
||||||
|
**Decision**: Render status-like values (check status, severity) via centralized badge semantics (BADGE-001), not ad-hoc mappings in feature pages.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Prevents drift in meaning/colors across the suite.
|
||||||
|
- Enables straightforward regression tests for new/changed status values.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Inline color mapping inside a Blade view: rejected (violates BADGE-001).
|
||||||
186
specs/074-verification-checklist/spec.md
Normal file
186
specs/074-verification-checklist/spec.md
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
# Feature Specification: Verification Checklist Framework (Enterprise-Ready)
|
||||||
|
|
||||||
|
**Feature Branch**: `074-verification-checklist`
|
||||||
|
**Created**: 2026-02-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Replace binary verification UX with a structured, reusable verification checklist attached to verification runs; DB-only viewing; enterprise semantics (reason codes, audit, idempotency, RBAC)."
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-03
|
||||||
|
|
||||||
|
- Q: What idempotency policy do we want for “Start verification”? → A: Dedupe only while a run is active (queued/running); once completed/failed, “Start verification” creates a new run.
|
||||||
|
- Q: Who should be allowed to view verification reports? → A: Any authenticated workspace member with access to the tenant scope may view reports; starting verification requires a separate capability.
|
||||||
|
- Q: What policy should we use for `reason_code` taxonomy? → A: Versioned central taxonomy with a small baseline set + reserved `ext.*` namespace for feature-specific extensions.
|
||||||
|
- Q: What’s the required evidence/redaction policy for `evidence` in check results? → A: Evidence is strictly structured safe pointers only (internal IDs, masked strings, hashes); never raw payloads, tokens, claims, headers, or full error bodies.
|
||||||
|
- Q: Should “Next steps” CTAs be links only, or can they trigger server-side actions? → A: Links only (navigation-only) in v1.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - Operator sees what’s wrong (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace member onboarding or operating a managed tenant, I can run “Verify access” and see a structured checklist that clearly shows which checks passed, which failed, and what to do next.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary value of verification: reduce ambiguity and enable fast, correct remediation.
|
||||||
|
|
||||||
|
**Independent Test**: Seed a verification run with a report containing mixed outcomes and confirm the viewer renders an accurate summary, per-check status, and next steps without making any external calls.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a completed verification run with 2 failed checks, **When** I open the verification report viewer, **Then** I see an overall summary (“Needs attention” or “Blocked”), counts, and the two failed checks with actionable next steps.
|
||||||
|
2. **Given** a verification run that is still in progress, **When** I open the viewer, **Then** I see a “Running” state and partial results (if available) without errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Deterministic starts (idempotency / dedupe) (Priority: P2)
|
||||||
|
|
||||||
|
As an operator, if I click “Start verification” multiple times for the same tenant + provider connection + flow, the system behaves deterministically: it does not start duplicate active runs and guides me to the already-running run.
|
||||||
|
|
||||||
|
**Why this priority**: Prevents confusing duplicates, reduces load, and makes support/debugging repeatable.
|
||||||
|
|
||||||
|
**Independent Test**: Attempt to start verification twice for the same identity and assert that only one active run exists and the UI returns a consistent “already running” outcome.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an active verification run exists for the same identity, **When** I click “Start verification”, **Then** no duplicate run is started and I am directed to view the active run/report.
|
||||||
|
2. **Given** no active verification run exists (including when the most recent run is completed or failed), **When** I click “Start verification”, **Then** a new run starts and I can view its report as it progresses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Least-privilege and safe disclosure (Priority: P3)
|
||||||
|
|
||||||
|
As a workspace member with access to the tenant scope (including read-only), I can view verification reports but cannot start verification unless I have the start capability. As a non-member, I cannot discover that a tenant or report exists.
|
||||||
|
|
||||||
|
**Why this priority**: Verification data can leak operational posture; access must follow least-privilege and “deny-as-not-found” for non-members.
|
||||||
|
|
||||||
|
**Independent Test**: Validate both authorization paths: read-only can view but cannot start; non-member receives a not-found response for all tenant-scoped verification routes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am a workspace member without the “start verification” capability, **When** I open the verification page, **Then** I can view past reports but the “Start verification” action is disabled and cannot be executed.
|
||||||
|
2. **Given** I am not a member of the workspace/tenant scope, **When** I attempt to access the verification report route, **Then** I receive a not-found response with no identifying hints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Report missing or malformed (e.g., run exists but report is absent or partial) → viewer shows a safe “Report unavailable” state and guidance.
|
||||||
|
- Unknown check keys or unknown reason codes (newer schema written by a newer verifier) → viewer degrades gracefully, still showing status/message/next steps when present.
|
||||||
|
- Large reports (near upper bound, e.g., 50 checks) → viewer remains responsive and summary counts remain correct.
|
||||||
|
- A run transitions from running → complete while the user is viewing → the viewer refreshes safely or the user can re-open without inconsistent states.
|
||||||
|
- Evidence contains unexpected fields → redaction rules prevent sensitive values from being displayed.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Introducing a separate monitoring/observability platform beyond the existing run tracking and audit log.
|
||||||
|
- Any workflow that requires client-side handling of secrets.
|
||||||
|
- A full overhaul of onboarding wizards beyond replacing/embedding verification status with the checklist viewer.
|
||||||
|
- Provider job orchestration redesign unrelated to running verification checks.
|
||||||
|
- Server-side actions triggered directly from the checklist viewer (v1 is navigation-only).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any external provider calls, any write/change behavior,
|
||||||
|
or any long-running/background work, the spec MUST describe safety gates (preview/confirmation/audit), tenant isolation,
|
||||||
|
run observability (run identity, visibility, and outcomes), and tests. If security-relevant DB-only actions intentionally
|
||||||
|
skip run tracking, the spec MUST describe the audit log entries.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
|
- state which authorization plane(s) are involved (tenant-scoped admin area vs platform/system admin area),
|
||||||
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
|
- explicitly define 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require explicit user confirmation,
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001 — Canonical verification report contract**: The system MUST generate a versioned “Verification Report” document for each verification run, including: schema version, flow identifier, identity/scope, generated timestamp, summary counts, and a list of check results.
|
||||||
|
|
||||||
|
- **FR-002 — Check result contract**: Each check result MUST include: stable key, title, status (pass/fail/warn/skip/running), severity (info/low/medium/high/critical), blocking flag, reason code, human-readable message, safe evidence pointers, and one or more “next steps” actions (where applicable).
|
||||||
|
|
||||||
|
Evidence MUST be strictly limited to structured safe pointers (internal IDs, masked strings, hashes) and MUST NOT contain raw payloads, tokens, claims, headers, or full error bodies.
|
||||||
|
|
||||||
|
Next steps in v1 MUST be navigation-only (links to internal pages or external documentation) and MUST NOT trigger server-side actions.
|
||||||
|
|
||||||
|
- **FR-003 — Stable reason code taxonomy**: The system MUST use stable, documented reason codes for failed/warned/skipped outcomes so that support, automation, and future UI changes remain consistent.
|
||||||
|
|
||||||
|
The taxonomy MUST include a small baseline set of cross-cutting codes and MUST reserve an `ext.*` namespace for flow-specific or check-specific extensions.
|
||||||
|
|
||||||
|
- **FR-004 — DB-only viewing**: Viewing a verification checklist MUST be read-only and MUST NOT trigger any external calls (e.g., no provider API calls, no HTTP calls, no background jobs started as a side effect of rendering).
|
||||||
|
|
||||||
|
- **FR-005 — Start verification creates a run**: Starting verification MUST create (or reuse, per dedupe policy) a new verification run record and begin executing the verification checks using existing background processing.
|
||||||
|
|
||||||
|
- **FR-006 — Dedupe / idempotency**: If a verification run is already active for the same identity (tenant + provider connection + flow), the system MUST NOT start a duplicate active run; it MUST present a clear “already running” outcome and an affordance to view the active run/report.
|
||||||
|
|
||||||
|
If no run is active (including when the most recent run is completed or failed), “Start verification” MUST create a new run.
|
||||||
|
|
||||||
|
- **FR-007 — Capability-first authorization**: Permission checks for viewing and starting verification MUST reference the canonical capability registry (no string-literal capability checks in feature code).
|
||||||
|
|
||||||
|
- **FR-008 — RBAC UX semantics**: Non-members attempting to access tenant-scoped verification pages/routes MUST receive not-found responses. Members lacking the “start” capability MUST be able to view reports but MUST NOT be able to start verification (UI disabled + server-side enforcement).
|
||||||
|
|
||||||
|
Viewing reports MUST NOT require the start capability.
|
||||||
|
|
||||||
|
- **FR-009 — Standardized UI semantics**: The viewer MUST render consistent status labels, a summary banner (e.g., Ready / Needs attention / Blocked), and per-check expandable details with standardized “Next steps” calls-to-action.
|
||||||
|
|
||||||
|
- **FR-010 — Reuse across suite**: The framework MUST be adoptable by multiple verification flows without re-implementing viewer logic, including: managed tenant onboarding verification, provider connection verification, RBAC setup verification, consent & permission verification, and future readiness/health checks.
|
||||||
|
|
||||||
|
- **FR-011 — Auditing**: Starting and completing verification MUST emit audit events with stable action identifiers and redaction rules, recording minimal metadata (workspace/tenant identifiers, run identifier, and result counts).
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Verification Flow**: A named verification context (e.g., managed tenant onboarding) that defines which checks run.
|
||||||
|
- **Verification Identity (Scope)**: The set of identifiers that uniquely represent “what is being verified” (tenant + provider connection + flow).
|
||||||
|
- **Verification Run**: A single execution attempt for a given identity that produces a report (and is auditable).
|
||||||
|
- **Verification Report**: A versioned, structured document attached to a run, containing summary and check results.
|
||||||
|
- **Check Definition**: A reusable definition of an atomic readiness check (key, title, expected preconditions, severity, blocking behavior).
|
||||||
|
- **Check Result**: The outcome of executing a check within a report.
|
||||||
|
- **Reason Code**: A stable, machine-readable classification of why a check is pass/fail/warn/skip.
|
||||||
|
- **Next Step**: An actionable remediation hint (label + optional destination/action) that helps the operator resolve a failed check.
|
||||||
|
- **Evidence Pointer**: Safe references that support diagnostics (IDs, masked strings, hashes), without exposing secrets.
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- A run-tracking mechanism already exists and can store an attached, versioned verification report per run.
|
||||||
|
- A canonical capability registry exists and is the source of truth for permission checks.
|
||||||
|
- An audit logging mechanism exists that can record start/complete events with redaction.
|
||||||
|
- Verification execution uses existing background processing patterns (no new observability platform is introduced).
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Workspace membership and tenant-scoped authorization boundaries are already modeled.
|
||||||
|
- Run visibility rules support “deny-as-not-found” behavior for non-members.
|
||||||
|
- UI surfaces exist (or can be added) where “Next steps” can route users for remediation.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001 (Clarity)**: In a usability test with a pre-seeded failed report, 90% of operators can identify the top blocking failure and the recommended next step within 60 seconds.
|
||||||
|
- **SC-002 (Determinism)**: When “Start verification” is triggered repeatedly for the same identity while a run is active, the system starts at most 1 active run (0 duplicates) and always provides a path to view the active run.
|
||||||
|
- **SC-003 (Safety / data minimization)**: Verification reports contain no secrets or tokens; evidence is limited to safe pointers (validated by automated tests and/or static checks).
|
||||||
|
- **SC-004 (Performance)**: The verification report viewer renders within 200ms server time for a typical report of up to 50 checks.
|
||||||
|
- **SC-005 (Authorization)**: Non-member access to tenant-scoped verification pages results in not-found responses in 100% of tested cases; members without the start capability cannot execute start actions in 100% of tested cases.
|
||||||
120
specs/074-verification-checklist/tasks.md
Normal file
120
specs/074-verification-checklist/tasks.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Tasks: 074 Verification Checklist Framework
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/074-verification-checklist/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Required (Pest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
- [x] T001 [US1] Add example reports under specs: `specs/074-verification-checklist/contracts/examples/*.json` (pass/fail/warn/running) aligned to `contracts/verification-report.schema.json`.
|
||||||
|
- [x] T002 [P] [US1] Add a small schema validation helper for reports (pure PHP, no external deps) in `app/Support/Verification/VerificationReportSchema.php` (version parsing + shape validation + graceful fallback).
|
||||||
|
- [x] T003 [P] [US1] Add report redaction/sanitization utility in `app/Support/Verification/VerificationReportSanitizer.php` (denylist keys/values; enforce evidence pointers only).
|
||||||
|
- [x] T004 [US1] Add value objects (or typed arrays) for report/check concepts in `app/Support/Verification/*` (status/severity enums or constants) to avoid ad-hoc strings throughout UI.
|
||||||
|
|
||||||
|
**RBAC & UX prereqs**
|
||||||
|
|
||||||
|
- [x] T005 [US3] Decide and document the start capability used per verification flow (v1: use `Capabilities::PROVIDER_RUN` for `provider.connection.check`; prefer existing constants in `app/Support/Auth/Capabilities.php`).
|
||||||
|
- [x] T006 [US3] Add/confirm central UI enforcement helper usage for “visible-but-disabled with tooltip” in verification start UI (use tenant-scoped `app/Support/Rbac/UiEnforcement.php` with a resolved `Tenant` record).
|
||||||
|
|
||||||
|
**Badges (BADGE-001)**
|
||||||
|
|
||||||
|
- [x] T007 [P] [US1] Add badge domains for verification status/severity in `app/Support/Badges/BadgeDomain.php`.
|
||||||
|
- [x] T008 [P] [US1] Add domain mappers in `app/Support/Badges/Domains/*` (e.g., `VerificationCheckStatusBadge`, `VerificationCheckSeverityBadge`).
|
||||||
|
- [x] T009 [US1] Register domains in `app/Support/Badges/BadgeCatalog.php`.
|
||||||
|
- [x] T010 [US1] Add mapping tests for new badge domains in `tests/Unit/Badges/*`.
|
||||||
|
|
||||||
|
**Checkpoint**: Report contract + sanitizer + badge domains exist; UI work can start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Operator sees what’s wrong (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Render a structured, DB-only verification report viewer for a run.
|
||||||
|
|
||||||
|
**Independent Test**: Seed an `OperationRun` with `context.verification_report` and assert the viewer renders the correct summary + per-check details, with no outbound HTTP.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T011 [US1] Add a viewer DB-only test (no outbound HTTP, no job dispatch) in `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` using `Http::fake()` + `Bus::fake()` and asserting no requests / no dispatch during page render (including a second render to cover Livewire refresh/poll paths).
|
||||||
|
- [x] T012 [US1] Add a redaction test in `tests/Feature/Verification/VerificationReportRedactionTest.php` to ensure forbidden keys/values never appear in stored/rendered evidence.
|
||||||
|
- [x] T013 [US1] Add a “malformed/missing report” viewer test in `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` (safe empty state).
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T014 [US1] Create a reusable viewer Blade partial in `resources/views/filament/components/verification-report-viewer.blade.php` (summary banner + counts + collapsible checks + next steps links-only).
|
||||||
|
- [x] T015 [US1] Create a Filament view entry/helper to render the viewer from an `OperationRun` in `app/Filament/Support/VerificationReportViewer.php` (or existing Filament helpers location), using only DB values.
|
||||||
|
- [x] T016 [US1] Integrate viewer into Monitoring → Operations run view page: update `app/Filament/Resources/OperationRunResource.php` (infolist) to show the verification report section when `context.verification_report` exists.
|
||||||
|
|
||||||
|
**Checkpoint**: A seeded report is readable in Monitoring; viewer is DB-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — Deterministic starts (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Starting verification is idempotent while active (dedupe) and guides users to the active run.
|
||||||
|
|
||||||
|
**Independent Test**: Start verification twice for the same identity and assert a single active run is used.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- [x] T017 [US2] Add a dedupe regression test in `tests/Feature/Verification/VerificationStartDedupeTest.php` asserting repeated starts reuse the same active run (leveraging the existing `OperationRunService::ensureRunWithIdentity()` behavior).
|
||||||
|
- [x] T018 [US2] Add a “new run after completion” test in `tests/Feature/Verification/VerificationStartAfterCompletionTest.php`.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T019 [US2] Add (or adapt) a small “start verification” service wrapper in `app/Services/Verification/StartVerification.php` that: authorizes, creates/reuses a run identity, enqueues a verifier job, and returns the run.
|
||||||
|
- [x] T020 [US2] Update the managed tenant onboarding verification step to route through the shared starter and replace the binary status UI with the shared verification report viewer (or a safe empty state) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`.
|
||||||
|
- [x] T021 [US2] Update provider connection verification start surface(s) (where present) to route through the same shared starter.
|
||||||
|
|
||||||
|
**Checkpoint**: Starts are deterministic and route users to the active run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 — Least-privilege and safe disclosure (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: View vs start capability split; non-members get 404; members lacking start capability get 403 on execution.
|
||||||
|
|
||||||
|
**Independent Test**: Readonly member can view report but cannot start; non-member cannot discover tenant/run.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- [x] T022 [US3] Add authorization tests for view vs start in `tests/Feature/Verification/VerificationAuthorizationTest.php` covering:
|
||||||
|
- tenant non-member → 404 on view + start
|
||||||
|
- tenant member without start capability → can view, start returns forbidden (403)
|
||||||
|
- tenant member with start capability → can start
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T023 [US3] Ensure start actions enforce server-side authorization via Gate/Policy (no UI-only enforcement) and use capability constants from `app/Support/Auth/Capabilities.php`.
|
||||||
|
- [x] T024 [US3] Ensure tenant-scope non-membership yields deny-as-not-found behavior for verification routes/actions (align with existing tenant routing patterns and helpers).
|
||||||
|
|
||||||
|
**Checkpoint**: Authorization behavior matches RBAC-UX contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Audit & Completion Events (Cross-cutting)
|
||||||
|
|
||||||
|
- [x] T025 [US1] Add a stable audit action ID for verification completion in `app/Support/Audit/AuditActionId.php`.
|
||||||
|
- [x] T026 [US1] Emit a completion audit event when a verification run finalizes (where run completion is set) using `app/Services/Audit/WorkspaceAuditLogger.php` with redacted metadata (run id + counts only).
|
||||||
|
- [x] T030 [US1] Add a report writer in `app/Support/Verification/VerificationReportWriter.php` that builds `context.verification_report`, derives `summary.overall` deterministically, enforces reason codes + evidence pointer-only policy, and runs sanitizer before persistence.
|
||||||
|
- [x] T031 [US1] Integrate the report writer into `app/Jobs/ProviderConnectionHealthCheckJob.php` so `provider.connection.check` writes a compliant `verification_report` to the run (both success and failure paths) before marking the run completed.
|
||||||
|
- [x] T032 [US1] Add a report-writing integration test in `tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php` ensuring the run ends with a valid, sanitized `context.verification_report` (and no forbidden evidence fields).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Regression Guards
|
||||||
|
|
||||||
|
- [x] T027 [P] Add UI polish for empty/missing report state in the viewer (no leaks of internal details).
|
||||||
|
- [x] T028 Run formatting: `vendor/bin/sail bin pint --dirty`.
|
||||||
|
- [x] T029 Run targeted tests: `vendor/bin/sail artisan test --compact tests/Feature/Verification`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- Phase 1 (Foundational) blocks all other phases.
|
||||||
|
- US1 can start after Phase 1; US2/US3 can proceed after Phase 1 but should reuse US1 primitives (viewer + sanitizer + badges).
|
||||||
|
- Audit completion (Phase 5) depends on the shared verification job/service that finalizes runs.
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Providers\MicrosoftProviderHealthCheck;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Verification\VerificationReportSchema;
|
||||||
|
|
||||||
|
it('writes a sanitized verification report for failed provider connection checks', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'payload' => [
|
||||||
|
'tenant_id' => (string) $connection->entra_tenant_id,
|
||||||
|
'client_id' => fake()->uuid(),
|
||||||
|
'client_secret' => fake()->sha1(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'running',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('getOrganization')
|
||||||
|
->once()
|
||||||
|
->andReturn(new GraphResponse(false, [], 401, ['Bearer super-secret-token']));
|
||||||
|
});
|
||||||
|
|
||||||
|
$job = new ProviderConnectionHealthCheckJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(
|
||||||
|
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
||||||
|
runs: app(OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $run->fresh();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('failed');
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$report = $context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
expect($report)->toBeArray();
|
||||||
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||||
|
expect(json_encode($report))->not->toContain('Bearer ');
|
||||||
|
expect($report['checks'][0]['reason_code'] ?? null)->toBe('authentication_failed');
|
||||||
|
|
||||||
|
foreach (($report['checks'] ?? []) as $check) {
|
||||||
|
expect($check)->toBeArray();
|
||||||
|
|
||||||
|
foreach (($check['evidence'] ?? []) as $pointer) {
|
||||||
|
expect($pointer)->toBeArray();
|
||||||
|
expect(array_keys($pointer))->toEqualCanonicalizing(['kind', 'value']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('action', AuditActionId::VerificationCompleted->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull();
|
||||||
|
expect($audit?->metadata)->toMatchArray([
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a verification report for successful provider connection checks', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'payload' => [
|
||||||
|
'tenant_id' => (string) $connection->entra_tenant_id,
|
||||||
|
'client_id' => fake()->uuid(),
|
||||||
|
'client_secret' => fake()->sha1(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'running',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('getOrganization')
|
||||||
|
->once()
|
||||||
|
->andReturn(new GraphResponse(true, [
|
||||||
|
'id' => 'org_123',
|
||||||
|
'displayName' => 'Org 123',
|
||||||
|
], 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
$job = new ProviderConnectionHealthCheckJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(
|
||||||
|
healthCheck: app(MicrosoftProviderHealthCheck::class),
|
||||||
|
runs: app(OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $run->fresh();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('succeeded');
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$report = $context['verification_report'] ?? null;
|
||||||
|
|
||||||
|
expect($report)->toBeArray();
|
||||||
|
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
|
||||||
|
expect($report['summary']['counts'] ?? [])->toMatchArray([
|
||||||
|
'total' => 1,
|
||||||
|
'pass' => 1,
|
||||||
|
'fail' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
112
tests/Feature/Verification/VerificationAuthorizationTest.php
Normal file
112
tests/Feature/Verification/VerificationAuthorizationTest.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
it('returns 404 for non-members on verification view and start', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant($otherTenant, role: 'readonly');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||||
|
->assertStatus(404);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(StartVerification::class)->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
))->toThrow(NotFoundHttpException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows readonly members to view verification reports but forbids starting verification', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification report');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(fn () => app(StartVerification::class)->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
))->toThrow(AuthorizationException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows members with start capability to start verification', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(StartVerification::class)->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->status)->toBe('started');
|
||||||
|
expect($result->run->type)->toBe('provider.connection.check');
|
||||||
|
expect($result->run->tenant_id)->toBe((int) $tenant->getKey());
|
||||||
|
expect($result->run->context)->toMatchArray([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('shows a safe empty state when a verification report is missing', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
|
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
||||||
|
->assertSee('Verification report')
|
||||||
|
->assertSee('Verification report unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a safe empty state when a verification report is malformed', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => [
|
||||||
|
'schema_version' => '1.0.0',
|
||||||
|
'flow' => 'provider.connection.check',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
|
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
||||||
|
->assertSee('Verification report')
|
||||||
|
->assertSee('Verification report unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('redacts forbidden evidence fields in rendered verification reports', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$report = json_decode(
|
||||||
|
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
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([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => $report,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
|
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
||||||
|
->assertSee('Verification report')
|
||||||
|
->assertSee('Token acquisition works')
|
||||||
|
->assertDontSee('access_token')
|
||||||
|
->assertDontSee('Bearer abc.def.ghi')
|
||||||
|
->assertDontSee('super-secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('renders the verification report viewer DB-only (no outbound HTTP, no job dispatch)', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$report = json_decode(
|
||||||
|
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => $report,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
|
$component = Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
||||||
|
->assertSee('Verification report')
|
||||||
|
->assertSee('Blocked')
|
||||||
|
->assertSee('Token acquisition works');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->call('$refresh')
|
||||||
|
->assertSee('Token acquisition works');
|
||||||
|
});
|
||||||
|
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
});
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
it('creates a new verification run after the previous run is completed', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$starter = app(StartVerification::class);
|
||||||
|
|
||||||
|
$first = $starter->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var OperationRun $firstRun */
|
||||||
|
$firstRun = $first->run->refresh();
|
||||||
|
|
||||||
|
app(OperationRunService::class)->updateRun(
|
||||||
|
$firstRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
$second = $starter->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($second->status)->toBe('started');
|
||||||
|
expect($second->run->getKey())->not->toBe($firstRun->getKey());
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(2);
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 2);
|
||||||
|
});
|
||||||
53
tests/Feature/Verification/VerificationStartDedupeTest.php
Normal file
53
tests/Feature/Verification/VerificationStartDedupeTest.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Verification\StartVerification;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
it('dedupes verification starts while a run is active', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$starter = app(StartVerification::class);
|
||||||
|
|
||||||
|
$first = $starter->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: ['wizard' => ['flow' => 'managed_tenant_onboarding']],
|
||||||
|
);
|
||||||
|
|
||||||
|
$second = $starter->providerConnectionCheck(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: ['wizard' => ['flow' => 'managed_tenant_onboarding']],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($first->run->getKey())->toBe($second->run->getKey());
|
||||||
|
expect($first->status)->toBe('started');
|
||||||
|
expect($second->status)->toBe('deduped');
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
});
|
||||||
68
tests/Unit/Badges/VerificationBadgesTest.php
Normal file
68
tests/Unit/Badges/VerificationBadgesTest.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
|
||||||
|
it('maps verification check status values to canonical badge semantics', function (): void {
|
||||||
|
$pass = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'pass');
|
||||||
|
expect($pass->label)->toBe('Pass');
|
||||||
|
expect($pass->color)->toBe('success');
|
||||||
|
|
||||||
|
$fail = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'fail');
|
||||||
|
expect($fail->label)->toBe('Fail');
|
||||||
|
expect($fail->color)->toBe('danger');
|
||||||
|
|
||||||
|
$warn = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'warn');
|
||||||
|
expect($warn->label)->toBe('Warn');
|
||||||
|
expect($warn->color)->toBe('warning');
|
||||||
|
|
||||||
|
$skip = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'skip');
|
||||||
|
expect($skip->label)->toBe('Skipped');
|
||||||
|
expect($skip->color)->toBe('gray');
|
||||||
|
|
||||||
|
$running = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'running');
|
||||||
|
expect($running->label)->toBe('Running');
|
||||||
|
expect($running->color)->toBe('info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps verification check severity values to canonical badge semantics', function (): void {
|
||||||
|
$info = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'info');
|
||||||
|
expect($info->label)->toBe('Info');
|
||||||
|
expect($info->color)->toBe('gray');
|
||||||
|
|
||||||
|
$low = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'low');
|
||||||
|
expect($low->label)->toBe('Low');
|
||||||
|
expect($low->color)->toBe('info');
|
||||||
|
|
||||||
|
$medium = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'medium');
|
||||||
|
expect($medium->label)->toBe('Medium');
|
||||||
|
expect($medium->color)->toBe('warning');
|
||||||
|
|
||||||
|
$high = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'high');
|
||||||
|
expect($high->label)->toBe('High');
|
||||||
|
expect($high->color)->toBe('danger');
|
||||||
|
|
||||||
|
$critical = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical');
|
||||||
|
expect($critical->label)->toBe('Critical');
|
||||||
|
expect($critical->color)->toBe('danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps verification report overall values to canonical badge semantics', function (): void {
|
||||||
|
$ready = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'ready');
|
||||||
|
expect($ready->label)->toBe('Ready');
|
||||||
|
expect($ready->color)->toBe('success');
|
||||||
|
|
||||||
|
$needsAttention = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention');
|
||||||
|
expect($needsAttention->label)->toBe('Needs attention');
|
||||||
|
expect($needsAttention->color)->toBe('warning');
|
||||||
|
|
||||||
|
$blocked = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'blocked');
|
||||||
|
expect($blocked->label)->toBe('Blocked');
|
||||||
|
expect($blocked->color)->toBe('danger');
|
||||||
|
|
||||||
|
$running = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'running');
|
||||||
|
expect($running->label)->toBe('Running');
|
||||||
|
expect($running->color)->toBe('info');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user