## Summary - add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support - add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament - add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity ## Validation - vendor/bin/sail bin pint --dirty --format agent - CI=1 vendor/bin/sail artisan test --compact - manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility ## Notes - Filament implementation remains on v5 with Livewire v4-compatible surfaces - canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php - finding exceptions stay out of global search in this rollout Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #184
270 lines
10 KiB
PHP
270 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Evidence;
|
|
|
|
use App\Jobs\GenerateEvidenceSnapshotJob;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
|
use App\Services\Evidence\Sources\BaselineDriftPostureSource;
|
|
use App\Services\Evidence\Sources\EntraAdminRolesSource;
|
|
use App\Services\Evidence\Sources\FindingsSummarySource;
|
|
use App\Services\Evidence\Sources\OperationsSummarySource;
|
|
use App\Services\Evidence\Sources\PermissionPostureSource;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\OperationRunType;
|
|
use InvalidArgumentException;
|
|
|
|
final class EvidenceSnapshotService
|
|
{
|
|
public function __construct(
|
|
private readonly OperationRunService $operationRuns,
|
|
private readonly WorkspaceAuditLogger $auditLogger,
|
|
private readonly EvidenceCompletenessEvaluator $completenessEvaluator,
|
|
) {}
|
|
|
|
public function generate(Tenant $tenant, User $user, bool $allowStale = false): EvidenceSnapshot
|
|
{
|
|
$fingerprint = $this->computeFingerprint($tenant);
|
|
$existing = $this->findExistingSnapshot($tenant, $fingerprint);
|
|
|
|
if ($existing instanceof EvidenceSnapshot) {
|
|
return $existing;
|
|
}
|
|
|
|
$operationRun = $this->operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::EvidenceSnapshotGenerate->value,
|
|
identityInputs: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
],
|
|
context: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'allow_stale' => $allowStale,
|
|
'fingerprint' => $fingerprint,
|
|
],
|
|
initiator: $user,
|
|
);
|
|
|
|
$snapshot = EvidenceSnapshot::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
'status' => EvidenceSnapshotStatus::Queued->value,
|
|
'completeness_state' => EvidenceCompletenessState::Missing->value,
|
|
'summary' => [
|
|
'allow_stale' => $allowStale,
|
|
'requested_at' => now()->toIso8601String(),
|
|
],
|
|
]);
|
|
|
|
$this->operationRuns->dispatchOrFail($operationRun, function () use ($snapshot, $operationRun): void {
|
|
GenerateEvidenceSnapshotJob::dispatch(
|
|
snapshotId: (int) $snapshot->getKey(),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
);
|
|
});
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $tenant->workspace,
|
|
action: AuditActionId::EvidenceSnapshotCreated,
|
|
context: [
|
|
'metadata' => [
|
|
'status' => EvidenceSnapshotStatus::Queued->value,
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'evidence_snapshot',
|
|
resourceId: (string) $snapshot->getKey(),
|
|
targetLabel: sprintf('Evidence snapshot #%d', (int) $snapshot->getKey()),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
tenant: $tenant,
|
|
);
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
public function refresh(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
|
{
|
|
$tenant = $snapshot->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
throw new InvalidArgumentException('Snapshot tenant could not be resolved.');
|
|
}
|
|
|
|
$refreshed = $this->generate($tenant, $user);
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $tenant->workspace,
|
|
action: AuditActionId::EvidenceSnapshotRefreshed,
|
|
context: [
|
|
'metadata' => [
|
|
'previous_snapshot_id' => (int) $snapshot->getKey(),
|
|
'new_snapshot_id' => (int) $refreshed->getKey(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'evidence_snapshot',
|
|
resourceId: (string) $refreshed->getKey(),
|
|
targetLabel: sprintf('Evidence snapshot #%d', (int) $refreshed->getKey()),
|
|
operationRunId: $refreshed->operation_run_id,
|
|
tenant: $tenant,
|
|
);
|
|
|
|
return $refreshed;
|
|
}
|
|
|
|
public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
|
{
|
|
$snapshot->forceFill([
|
|
'status' => EvidenceSnapshotStatus::Expired->value,
|
|
'expires_at' => now(),
|
|
])->save();
|
|
|
|
$tenant = $snapshot->tenant;
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
$this->auditLogger->log(
|
|
workspace: $tenant->workspace,
|
|
action: AuditActionId::EvidenceSnapshotExpired,
|
|
context: [
|
|
'metadata' => [
|
|
'before_status' => EvidenceSnapshotStatus::Active->value,
|
|
'after_status' => EvidenceSnapshotStatus::Expired->value,
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'evidence_snapshot',
|
|
resourceId: (string) $snapshot->getKey(),
|
|
targetLabel: sprintf('Evidence snapshot #%d', (int) $snapshot->getKey()),
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
/**
|
|
* @return list<EvidenceSourceProvider>
|
|
*/
|
|
public function providers(): array
|
|
{
|
|
return [
|
|
app(FindingsSummarySource::class),
|
|
app(PermissionPostureSource::class),
|
|
app(EntraAdminRolesSource::class),
|
|
app(BaselineDriftPostureSource::class),
|
|
app(OperationsSummarySource::class),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{items: list<array<string, mixed>>, fingerprint: string, completeness: string, summary: array<string, mixed>}
|
|
*/
|
|
public function buildSnapshotPayload(Tenant $tenant): array
|
|
{
|
|
$items = [];
|
|
$fingerprintPayload = [];
|
|
|
|
foreach ($this->providers() as $provider) {
|
|
$item = $provider->collect($tenant);
|
|
$items[] = $item;
|
|
$fingerprintPayload[$provider->key()] = $item['fingerprint_payload'];
|
|
}
|
|
|
|
$completeness = $this->completenessEvaluator->evaluate(array_map(
|
|
static fn (array $item): array => [
|
|
'state' => (string) $item['state'],
|
|
'required' => (bool) $item['required'],
|
|
],
|
|
$items,
|
|
));
|
|
$itemsByKey = collect($items)->keyBy('dimension_key');
|
|
$findingsSummary = is_array($itemsByKey->get('findings_summary')['summary_payload'] ?? null)
|
|
? $itemsByKey->get('findings_summary')['summary_payload']
|
|
: [];
|
|
$operationsSummary = is_array($itemsByKey->get('operations_summary')['summary_payload'] ?? null)
|
|
? $itemsByKey->get('operations_summary')['summary_payload']
|
|
: [];
|
|
|
|
$summary = [
|
|
'dimension_count' => count($items),
|
|
'finding_count' => (int) ($findingsSummary['count'] ?? 0),
|
|
'report_count' => count(array_filter($items, static fn (array $item): bool => in_array($item['dimension_key'], ['permission_posture', 'entra_admin_roles'], true) && $item['source_record_id'] !== null)),
|
|
'operation_count' => (int) ($operationsSummary['operation_count'] ?? 0),
|
|
'missing_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Missing->value)),
|
|
'stale_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Stale->value)),
|
|
'dimensions' => array_map(static fn (array $item): array => [
|
|
'key' => $item['dimension_key'],
|
|
'state' => $item['state'],
|
|
'required' => $item['required'],
|
|
], $items),
|
|
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
|
? $findingsSummary['risk_acceptance']
|
|
: [
|
|
'status_marked_count' => 0,
|
|
'valid_governed_count' => 0,
|
|
'warning_count' => 0,
|
|
'expired_count' => 0,
|
|
'revoked_count' => 0,
|
|
'missing_exception_count' => 0,
|
|
],
|
|
'hardening' => [
|
|
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
|
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
|
'rbac_canary_results' => $tenant->rbac_canary_results,
|
|
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
|
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
|
],
|
|
];
|
|
|
|
return [
|
|
'items' => $items,
|
|
'fingerprint' => EvidenceSnapshotFingerprint::hash($fingerprintPayload),
|
|
'completeness' => $completeness->value,
|
|
'summary' => $summary,
|
|
];
|
|
}
|
|
|
|
public function computeFingerprint(Tenant $tenant): string
|
|
{
|
|
return $this->buildSnapshotPayload($tenant)['fingerprint'];
|
|
}
|
|
|
|
public function checkActiveRun(Tenant $tenant): bool
|
|
{
|
|
return $this->operationRuns->findCanonicalRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::EvidenceSnapshotGenerate->value,
|
|
identityInputs: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'fingerprint' => $this->computeFingerprint($tenant),
|
|
],
|
|
) !== null;
|
|
}
|
|
|
|
private function findExistingSnapshot(Tenant $tenant, string $fingerprint): ?EvidenceSnapshot
|
|
{
|
|
return EvidenceSnapshot::query()
|
|
->forTenant((int) $tenant->getKey())
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('fingerprint', $fingerprint)
|
|
->where('status', EvidenceSnapshotStatus::Active->value)
|
|
->where(function ($query): void {
|
|
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
|
})
|
|
->first();
|
|
}
|
|
}
|