TenantAtlas/app/Jobs/GenerateReviewPackJob.php
2026-02-23 17:57:29 +01:00

393 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ReviewPackStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
use ZipArchive;
class GenerateReviewPackJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $reviewPackId,
public int $operationRunId,
) {}
public function handle(OperationRunService $operationRunService): void
{
$reviewPack = ReviewPack::query()->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 Findings (open + acknowledged)
$findings = Finding::query()
->where('tenant_id', $tenantId)
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
->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<string, StoredReport> $storedReports
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
* @return array<string, ?string>
*/
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<string, string>
*/
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<int, Finding> $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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, string> $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)],
],
);
}
}