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; } // 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, $operationRunService); } catch (Throwable $e) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage()); throw $e; } } private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void { $options = $reviewPack->options ?? []; $includePii = (bool) ($options['include_pii'] ?? true); $includeOperations = (bool) ($options['include_operations'] ?? true); $tenantId = (int) $tenant->getKey(); // 1. Collect StoredReports $storedReports = StoredReport::query() ->where('tenant_id', $tenantId) ->whereIn('report_type', [ StoredReport::REPORT_TYPE_PERMISSION_POSTURE, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES, ]) ->get() ->keyBy('report_type'); // 2. Collect open findings $findings = Finding::query() ->where('tenant_id', $tenantId) ->whereIn('status', Finding::openStatusesForQuery()) ->orderBy('severity') ->orderBy('created_at') ->get(); // 3. Collect tenant hardening fields $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, ]; // 4. Collect recent OperationRuns (30 days) $recentOperations = $includeOperations ? OperationRun::query() ->where('tenant_id', $tenantId) ->where('created_at', '>=', now()->subDays(30)) ->orderByDesc('created_at') ->get() : collect(); // 5. Data freshness $dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant); // 6. Build file map $fileMap = $this->buildFileMap( storedReports: $storedReports, findings: $findings, hardening: $hardening, recentOperations: $recentOperations, tenant: $tenant, dataFreshness: $dataFreshness, 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' => $findings->count(), 'report_count' => $storedReports->count(), 'operation_count' => $recentOperations->count(), 'data_freshness' => $dataFreshness, ]; // 12. Update ReviewPack $retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90); $reviewPack->update([ 'status' => ReviewPackStatus::Ready->value, '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, ); } /** * @param \Illuminate\Support\Collection $storedReports * @param \Illuminate\Database\Eloquent\Collection $findings * @return array */ private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array { return [ 'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(), 'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(), 'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(), 'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(), ]; } /** * Build the file map for the ZIP contents. * * @return array */ private function buildFileMap( $storedReports, $findings, array $hardening, $recentOperations, Tenant $tenant, array $dataFreshness, 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(), '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 $entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES); $files['reports/entra_admin_roles.json'] = json_encode( $entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, ); // reports/permission_posture.json $postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE); $files['reports/permission_posture.json'] = json_encode( $postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, ); // summary.json $files['summary.json'] = json_encode([ 'data_freshness' => $dataFreshness, 'finding_count' => $findings->count(), 'report_count' => $storedReports->count(), 'operation_count' => $recentOperations->count(), ], 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+'); fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']); foreach ($findings as $finding) { fputcsv($handle, [ $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(), ]); } 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+'); fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']); foreach ($operations as $operation) { fputcsv($handle, [ $operation->id, $operation->type, $operation->status, $operation->outcome, $includePii ? ($operation->user?->name ?? '') : '[REDACTED]', $operation->started_at?->toIso8601String(), $operation->completed_at?->toIso8601String(), ]); } rewind($handle); $content = stream_get_contents($handle); fclose($handle); return $content; } /** * Redact PII from a report payload. * * @param array $payload * @return array */ private function redactReportPayload(array $payload, bool $includePii): array { if ($includePii) { return $payload; } return $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; } /** * 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]); $operationRunService->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [ ['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)], ], ); } }