with(['tenant', 'evidenceSnapshot.items'])->find($this->reviewPackId); $operationRun = OperationRun::query()->find($this->operationRunId); if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) { Log::warning('GenerateReviewPackJob: missing records', [ 'review_pack_id' => $this->reviewPackId, 'operation_run_id' => $this->operationRunId, ]); return; } $tenant = $reviewPack->tenant; if (! $tenant instanceof Tenant) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant not found'); return; } $snapshot = $reviewPack->evidenceSnapshot; if (! $snapshot instanceof EvidenceSnapshot) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'missing_snapshot', 'Evidence snapshot not found'); return; } // Mark running via OperationRunService (auto-sets started_at) $operationRunService->updateRun($operationRun, OperationRunStatus::Running->value); $reviewPack->update(['status' => ReviewPackStatus::Generating->value]); try { $this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService); } catch (Throwable $e) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage()); throw $e; } } private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void { $options = $reviewPack->options ?? []; $includePii = (bool) ($options['include_pii'] ?? true); $includeOperations = (bool) ($options['include_operations'] ?? true); $items = $snapshot->items->keyBy('dimension_key'); $findingsPayload = $this->itemSummaryPayload($items->get('findings_summary')); $permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture')); $entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles')); $operationsPayload = $this->itemSummaryPayload($items->get('operations_summary')); $riskAcceptance = is_array($snapshot->summary['risk_acceptance'] ?? null) ? $snapshot->summary['risk_acceptance'] : (is_array($findingsPayload['risk_acceptance'] ?? null) ? $findingsPayload['risk_acceptance'] : []); $findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []); $recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []); $hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : []; $dataFreshness = $this->computeDataFreshness($items); // 6. Build file map $fileMap = $this->buildFileMap( findings: $findings, hardening: $hardening, permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [], entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []], recentOperations: $recentOperations, tenant: $tenant, snapshot: $snapshot, dataFreshness: $dataFreshness, riskAcceptance: $riskAcceptance, includePii: $includePii, includeOperations: $includeOperations, ); // 7. Assemble ZIP $tempFile = tempnam(sys_get_temp_dir(), 'review-pack-'); try { $this->assembleZip($tempFile, $fileMap); // 8. Compute SHA-256 $sha256 = hash_file('sha256', $tempFile); $fileSize = filesize($tempFile); // 9. Store on exports disk $filePath = sprintf( 'review-packs/%s/%s.zip', $tenant->external_id, now()->format('Y-m-d-His'), ); Storage::disk('exports')->put($filePath, file_get_contents($tempFile)); } finally { if (file_exists($tempFile)) { unlink($tempFile); } } // 10. Compute fingerprint $fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options); // 11. Compute summary $summary = [ 'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()), 'report_count' => (int) ($snapshot->summary['report_count'] ?? 0), 'operation_count' => $recentOperations->count(), 'data_freshness' => $dataFreshness, 'risk_acceptance' => $riskAcceptance, 'evidence_resolution' => [ 'outcome' => 'resolved', 'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, ], ]; // 12. Update ReviewPack $retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90); $reviewPack->update([ 'status' => ReviewPackStatus::Ready->value, 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'fingerprint' => $fingerprint, 'sha256' => $sha256, 'file_size' => $fileSize, 'file_path' => $filePath, 'file_disk' => 'exports', 'generated_at' => now(), 'expires_at' => now()->addDays($retentionDays), 'summary' => $summary, ]); // 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification) $operationRunService->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, summaryCounts: $summary, ); } /** * @return array */ private function computeDataFreshness($items): array { return [ 'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(), 'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(), 'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(), 'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(), ]; } /** * Build the file map for the ZIP contents. * * @return array */ private function buildFileMap( $findings, array $hardening, array $permissionPosture, array $entraAdminRoles, $recentOperations, Tenant $tenant, EvidenceSnapshot $snapshot, array $dataFreshness, array $riskAcceptance, bool $includePii, bool $includeOperations, ): array { $files = []; // findings.csv $files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii); // hardening.json $files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); // metadata.json $files['metadata.json'] = json_encode([ 'version' => '1.0', 'tenant_id' => $tenant->external_id, 'tenant_name' => $includePii ? $tenant->name : '[REDACTED]', 'generated_at' => now()->toIso8601String(), 'evidence_snapshot' => [ 'id' => (int) $snapshot->getKey(), 'fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, 'generated_at' => $snapshot->generated_at?->toIso8601String(), ], 'redaction_integrity' => [ 'protected_values_hidden' => true, 'note' => RedactionIntegrity::protectedValueNote(), ], 'options' => [ 'include_pii' => $includePii, 'include_operations' => $includeOperations, ], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); // operations.csv $files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii); // reports/entra_admin_roles.json $files['reports/entra_admin_roles.json'] = json_encode( $this->redactReportPayload($entraAdminRoles, $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, ); // reports/permission_posture.json $files['reports/permission_posture.json'] = json_encode( $this->redactReportPayload($permissionPosture, $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, ); // summary.json $files['summary.json'] = json_encode([ 'data_freshness' => $dataFreshness, 'finding_count' => $findings->count(), 'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])), 'operation_count' => $recentOperations->count(), 'risk_acceptance' => $riskAcceptance, 'snapshot_id' => (int) $snapshot->getKey(), ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); return $files; } /** * Build findings CSV content. * * @param \Illuminate\Database\Eloquent\Collection $findings */ private function buildFindingsCsv($findings, bool $includePii): string { $handle = fopen('php://temp', 'r+'); $this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']); foreach ($findings as $finding) { $row = $finding instanceof Finding ? [ $finding->id, $finding->finding_type, $finding->severity, $finding->status, $includePii ? ($finding->title ?? '') : '[REDACTED]', $includePii ? ($finding->description ?? '') : '[REDACTED]', $finding->created_at?->toIso8601String(), $finding->updated_at?->toIso8601String(), ] : [ $finding['id'] ?? '', $finding['finding_type'] ?? '', $finding['severity'] ?? '', $finding['status'] ?? '', $includePii ? ($finding['title'] ?? '') : '[REDACTED]', $includePii ? ($finding['description'] ?? '') : '[REDACTED]', $finding['created_at'] ?? '', $finding['updated_at'] ?? '', ]; $this->writeCsvRow($handle, [ ...$row, ]); } rewind($handle); $content = stream_get_contents($handle); fclose($handle); return $content; } /** * Build operations CSV content. */ private function buildOperationsCsv($operations, bool $includePii): string { $handle = fopen('php://temp', 'r+'); $this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']); foreach ($operations as $operation) { $row = $operation instanceof OperationRun ? [ $operation->id, $operation->type, $operation->status, $operation->outcome, $includePii ? ($operation->user?->name ?? '') : '[REDACTED]', $operation->started_at?->toIso8601String(), $operation->completed_at?->toIso8601String(), ] : [ $operation['id'] ?? '', $operation['type'] ?? '', $operation['status'] ?? '', $operation['outcome'] ?? '', $includePii ? ($operation['initiator_name'] ?? '') : '[REDACTED]', $operation['started_at'] ?? '', $operation['completed_at'] ?? '', ]; $this->writeCsvRow($handle, [ ...$row, ]); } rewind($handle); $content = stream_get_contents($handle); fclose($handle); return $content; } /** * @param resource $handle * @param array $row */ private function writeCsvRow($handle, array $row): void { fputcsv($handle, $row, ',', '"', '\\'); } /** * Redact PII from a report payload. * * @param array $payload * @return array */ private function redactReportPayload(array $payload, bool $includePii): array { $payload = $this->redactProtectedPayload($payload); return $includePii ? $payload : $this->redactArrayPii($payload); } /** * Recursively redact PII fields from an array. * * @param array $data * @return array */ private function redactArrayPii(array $data): array { $piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail']; foreach ($data as $key => $value) { if (is_string($key) && in_array($key, $piiKeys, true)) { $data[$key] = '[REDACTED]'; } elseif (is_array($value)) { $data[$key] = $this->redactArrayPii($value); } } return $data; } /** * @param array $data * @param array $segments * @return array */ private function redactProtectedPayload(array $data, array $segments = []): array { foreach ($data as $key => $value) { $nextSegments = [...$segments, (string) $key]; $jsonPointer = $this->jsonPointer($nextSegments); if (is_string($key) && $this->classifier()->protectsField('snapshot', $key, $jsonPointer)) { $data[$key] = SecretClassificationService::REDACTED; continue; } if (is_array($value)) { $data[$key] = $this->redactProtectedPayload($value, $nextSegments); continue; } if (is_string($value)) { $data[$key] = $this->classifier()->sanitizeAuditString($value); } } return $data; } /** * @param array $segments */ private function jsonPointer(array $segments): string { if ($segments === []) { return '/'; } return '/'.implode('/', array_map( static fn (string $segment): string => str_replace(['~', '/'], ['~0', '~1'], $segment), $segments, )); } private function classifier(): SecretClassificationService { return app(SecretClassificationService::class); } /** * Assemble a ZIP file from a file map. * * @param array $fileMap */ private function assembleZip(string $tempFile, array $fileMap): void { $zip = new ZipArchive; $result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE); if ($result !== true) { throw new \RuntimeException("Failed to create ZIP archive: error code {$result}"); } // Add files in alphabetical order for deterministic output ksort($fileMap); foreach ($fileMap as $filename => $content) { $zip->addFromString($filename, $content); } $zip->close(); } private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void { $reviewPack->update([ 'status' => ReviewPackStatus::Failed->value, 'summary' => array_merge($reviewPack->summary ?? [], [ 'evidence_resolution' => array_merge($reviewPack->summary['evidence_resolution'] ?? [], [ 'outcome' => $reasonCode, 'reasons' => [mb_substr($errorMessage, 0, 500)], ]), ]), ]); $operationRunService->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [ ['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)], ], ); } private function itemSummaryPayload(mixed $item): array { if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) { return []; } return $item->summary_payload; } }