*/ 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, ); if (! $operationRun->wasRecentlyCreated) { $queuedPack = $this->findPackForRun($tenant, $operationRun); if ($queuedPack instanceof ReviewPack) { return $queuedPack; } } $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; } /** * Create a review-derived executive pack. * * @param array $options */ public function generateFromReview(TenantReview $review, User $user, array $options = []): ReviewPack { $review->loadMissing(['tenant', 'evidenceSnapshot', 'sections']); $tenant = $review->tenant; $snapshot = $review->evidenceSnapshot; if (! $tenant instanceof Tenant || ! $snapshot instanceof EvidenceSnapshot) { throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.'); } $options = $this->normalizeOptions($options); $fingerprint = $this->computeFingerprintForReview($review, $options); $existing = $this->findExistingPackForReview($review, $fingerprint); if ($existing instanceof ReviewPack) { $this->logReviewExport($review, $user, $existing, 'reused'); return $existing; } $operationRun = $this->operationRunService->ensureRun( tenant: $tenant, type: OperationRunType::ReviewPackGenerate->value, inputs: [ 'tenant_review_id' => (int) $review->getKey(), 'include_pii' => $options['include_pii'], 'include_operations' => $options['include_operations'], 'evidence_snapshot_id' => (int) $snapshot->getKey(), ], initiator: $user, ); if (! $operationRun->wasRecentlyCreated) { $queuedPack = $this->findPackForRun($tenant, $operationRun); if ($queuedPack instanceof ReviewPack) { $this->logReviewExport($review, $user, $queuedPack, 'reused_active_run'); return $queuedPack; } } $reviewPack = ReviewPack::create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'tenant_review_id' => (int) $review->getKey(), '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' => [ 'tenant_review_id' => (int) $review->getKey(), 'review_status' => (string) $review->status, 'review_completeness_state' => (string) $review->completeness_state, 'section_count' => $review->sections->count(), '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(), ); }); $this->logReviewExport($review, $user, $reviewPack, 'queued'); 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(); } public function findExistingPackForReview(TenantReview $review, string $fingerprint): ?ReviewPack { return ReviewPack::query() ->where('tenant_review_id', (int) $review->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(); } public function checkActiveRunForReview(TenantReview $review): bool { return OperationRun::query() ->where('tenant_id', (int) $review->tenant_id) ->where('type', OperationRunType::ReviewPackGenerate->value) ->whereJsonContains('context->tenant_review_id', (int) $review->getKey()) ->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)); } public function computeFingerprintForReview(TenantReview $review, array $options): string { $data = [ 'tenant_review_id' => (int) $review->getKey(), 'review_fingerprint' => (string) $review->fingerprint, 'review_status' => (string) $review->status, '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; } private function findPackForRun(Tenant $tenant, OperationRun $operationRun): ?ReviewPack { return ReviewPack::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('operation_run_id', (int) $operationRun->getKey()) ->latest('id') ->first(); } private function logReviewExport(TenantReview $review, User $user, ReviewPack $reviewPack, string $mode): void { $tenant = $review->tenant; if (! $tenant instanceof Tenant) { return; } $this->auditLogger->log( workspace: $tenant->workspace, action: AuditActionId::TenantReviewExported, context: [ 'metadata' => [ 'review_id' => (int) $review->getKey(), 'review_pack_id' => (int) $reviewPack->getKey(), 'mode' => $mode, 'status' => (string) $reviewPack->status, ], ], actor: $user, resourceType: 'tenant_review', resourceId: (string) $review->getKey(), targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()), operationRunId: $reviewPack->operation_run_id, tenant: $tenant, ); } }