TenantAtlas/app/Services/Evidence/EvidenceSnapshotService.php
2026-03-20 02:05:50 +01:00

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