- ReviewPackService: generate, fingerprint dedupe, signed download URL - GenerateReviewPackJob: 12-step pipeline, ZIP assembly, failure handling - ReviewPackDownloadController: signed URL streaming with SHA-256 header - ReviewPackResource: list/view pages, generate/expire/download actions - TenantReviewPackCard: dashboard widget with 5 display states - ReviewPackPolicy: RBAC via REVIEW_PACK_VIEW/MANAGE capabilities - PruneReviewPacksCommand: retention automation + hard-delete option - ReviewPackStatusNotification: database channel, ready/failed payloads - Schedule: daily prune + entra admin roles, posture:dispatch deferred - AlertRuleResource: hide sla_due from dropdown (backward compat kept) - 59 passing tests across 7 test files (1 skipped: posture deferred) - All 36 tasks completed per tasks.md
420 lines
14 KiB
PHP
420 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\Notifications\ReviewPackStatusNotification;
|
|
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(): 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, 'tenant_not_found', 'Tenant not found');
|
|
|
|
return;
|
|
}
|
|
|
|
// Mark running
|
|
$operationRun->update([
|
|
'status' => OperationRunStatus::Running->value,
|
|
'started_at' => now(),
|
|
]);
|
|
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
|
|
|
try {
|
|
$this->executeGeneration($reviewPack, $operationRun, $tenant);
|
|
} catch (Throwable $e) {
|
|
$this->markFailed($reviewPack, $operationRun, 'generation_error', $e->getMessage());
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant): 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
|
|
$operationRun->update([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now(),
|
|
'summary_counts' => $summary,
|
|
]);
|
|
|
|
// 14. Notify initiator
|
|
$this->notifyInitiator($reviewPack, 'ready');
|
|
}
|
|
|
|
/**
|
|
* @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, string $reasonCode, string $errorMessage): void
|
|
{
|
|
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
|
|
|
$operationRun->update([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => now(),
|
|
'context' => array_merge($operationRun->context ?? [], [
|
|
'reason_code' => $reasonCode,
|
|
'error_message' => mb_substr($errorMessage, 0, 500),
|
|
]),
|
|
]);
|
|
|
|
$this->notifyInitiator($reviewPack, 'failed', $reasonCode);
|
|
}
|
|
|
|
private function notifyInitiator(ReviewPack $reviewPack, string $status, ?string $reasonCode = null): void
|
|
{
|
|
$initiator = $reviewPack->initiator;
|
|
|
|
if (! $initiator) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$initiator->notify(new ReviewPackStatusNotification($reviewPack, $status, $reasonCode));
|
|
} catch (Throwable $e) {
|
|
Log::warning('Failed to send ReviewPack notification', [
|
|
'review_pack_id' => $reviewPack->getKey(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|