*/ private const REQUIRED_EVIDENCE_DIMENSIONS = [ 'findings_summary', 'permission_posture', 'entra_admin_roles', 'baseline_drift_posture', 'operations_summary', ]; /** * Create an OperationRun + ReviewPack and dispatch the generation job. * * @param array $options */ public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack { $options = $this->normalizeOptions($options); $snapshot = $this->resolveSnapshot($tenant); $fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options); $existing = $this->findExistingPack($tenant, $fingerprint); if ($existing instanceof ReviewPack) { return $existing; } $operationRun = $this->operationRunService->ensureRun( tenant: $tenant, type: OperationRunType::ReviewPackGenerate->value, inputs: [ 'include_pii' => $options['include_pii'], 'include_operations' => $options['include_operations'], 'evidence_snapshot_id' => (int) $snapshot->getKey(), ], initiator: $user, ); $reviewPack = ReviewPack::create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'operation_run_id' => (int) $operationRun->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), 'status' => ReviewPackStatus::Queued->value, 'options' => $options, 'summary' => [ 'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null) ? $snapshot->summary['risk_acceptance'] : [], 'evidence_resolution' => [ 'outcome' => 'resolved', 'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, 'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS, ], ], ]); $this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void { GenerateReviewPackJob::dispatch( reviewPackId: (int) $reviewPack->getKey(), operationRunId: (int) $operationRun->getKey(), ); }); return $reviewPack; } /** * Compute a deterministic fingerprint for deduplication. * * @param array $options */ public function computeFingerprint(Tenant $tenant, array $options): string { return $this->computeFingerprintForSnapshot($this->resolveSnapshot($tenant), $this->normalizeOptions($options)); } /** * Generate a signed download URL for a review pack. */ public function generateDownloadUrl(ReviewPack $pack): string { $ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60); return URL::signedRoute( 'admin.review-packs.download', ['reviewPack' => $pack->getKey()], now()->addMinutes($ttlMinutes), ); } /** * Find an existing ready, non-expired pack with the same fingerprint. */ public function findExistingPack(Tenant $tenant, string $fingerprint): ?ReviewPack { return ReviewPack::query() ->forTenant((int) $tenant->getKey()) ->ready() ->where('fingerprint', $fingerprint) ->where('expires_at', '>', now()) ->first(); } /** * Check if a generation run is currently active for this tenant. */ public function checkActiveRun(Tenant $tenant): bool { return OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('type', OperationRunType::ReviewPackGenerate->value) ->active() ->exists(); } /** * @param array $options * @return array{include_pii: bool, include_operations: bool} */ private function normalizeOptions(array $options): array { return [ 'include_pii' => (bool) ($options['include_pii'] ?? config('tenantpilot.review_pack.include_pii_default', true)), 'include_operations' => (bool) ($options['include_operations'] ?? config('tenantpilot.review_pack.include_operations_default', true)), ]; } private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string { $data = [ 'tenant_id' => (int) $snapshot->tenant_id, 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'evidence_fingerprint' => (string) $snapshot->fingerprint, 'include_pii' => (bool) ($options['include_pii'] ?? true), 'include_operations' => (bool) ($options['include_operations'] ?? true), ]; return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); } private function resolveSnapshot(Tenant $tenant): EvidenceSnapshot { $result = $this->snapshotResolver->resolve(new EvidenceResolutionRequest( workspaceId: (int) $tenant->workspace_id, tenantId: (int) $tenant->getKey(), requiredDimensions: self::REQUIRED_EVIDENCE_DIMENSIONS, )); if (! $result->isResolved()) { throw new ReviewPackEvidenceResolutionException($result); } return $result->snapshot; } }