909 lines
35 KiB
PHP
909 lines
35 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Services\Intune\SecretClassificationService;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\RedactionIntegrity;
|
|
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 int $timeout = 240;
|
|
|
|
public bool $failOnTimeout = true;
|
|
|
|
public function __construct(
|
|
public int $reviewPackId,
|
|
public int $operationRunId,
|
|
) {}
|
|
|
|
public function handle(OperationRunService $operationRunService): void
|
|
{
|
|
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->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
|
|
{
|
|
$review = $reviewPack->tenantReview;
|
|
|
|
if ($review instanceof TenantReview) {
|
|
$this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService);
|
|
|
|
return;
|
|
}
|
|
|
|
$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,
|
|
);
|
|
}
|
|
|
|
private function executeReviewDerivedGeneration(
|
|
ReviewPack $reviewPack,
|
|
TenantReview $review,
|
|
OperationRun $operationRun,
|
|
Tenant $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
OperationRunService $operationRunService,
|
|
): void {
|
|
$options = $reviewPack->options ?? [];
|
|
$includePii = (bool) ($options['include_pii'] ?? true);
|
|
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
|
$generatedAt = now();
|
|
|
|
$fileMap = $this->buildReviewDerivedFileMap(
|
|
reviewPack: $reviewPack,
|
|
review: $review,
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
includePii: $includePii,
|
|
includeOperations: $includeOperations,
|
|
generatedAt: $generatedAt,
|
|
);
|
|
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
|
|
try {
|
|
$this->assembleZip($tempFile, $fileMap);
|
|
|
|
$sha256 = hash_file('sha256', $tempFile);
|
|
$fileSize = filesize($tempFile);
|
|
$filePath = sprintf(
|
|
'review-packs/%s/review-%d-%s.zip',
|
|
$tenant->external_id,
|
|
(int) $review->getKey(),
|
|
$generatedAt->format('Y-m-d-His'),
|
|
);
|
|
|
|
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
|
} finally {
|
|
if (file_exists($tempFile)) {
|
|
unlink($tempFile);
|
|
}
|
|
}
|
|
|
|
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
$summary = [
|
|
'tenant_review_id' => (int) $review->getKey(),
|
|
'review_status' => (string) $review->status,
|
|
'review_completeness_state' => (string) $review->completeness_state,
|
|
'section_count' => $review->sections->count(),
|
|
'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0),
|
|
'report_count' => (int) ($reviewSummary['report_count'] ?? 0),
|
|
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
|
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
|
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
|
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
|
'evidence_resolution' => [
|
|
'outcome' => 'resolved',
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
],
|
|
];
|
|
|
|
$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' => $generatedAt,
|
|
'expires_at' => $generatedAt->copy()->addDays($retentionDays),
|
|
'summary' => $summary,
|
|
]);
|
|
|
|
$review->update([
|
|
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
'summary' => array_merge($reviewSummary, [
|
|
'has_ready_export' => true,
|
|
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
]),
|
|
]);
|
|
|
|
$operationRunService->updateRun(
|
|
$operationRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
summaryCounts: [
|
|
'created' => 1,
|
|
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
'errors_recorded' => 0,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, ?string>
|
|
*/
|
|
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<string, string>
|
|
*/
|
|
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<int, Finding> $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<int, mixed> $row
|
|
*/
|
|
private function writeCsvRow($handle, array $row): void
|
|
{
|
|
fputcsv($handle, $row, ',', '"', '\\');
|
|
}
|
|
|
|
/**
|
|
* Redact PII from a report payload.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<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;
|
|
}
|
|
|
|
/**
|
|
* @param array<int|string, mixed> $data
|
|
* @param array<int, string> $segments
|
|
* @return array<int|string, mixed>
|
|
*/
|
|
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<int, string> $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<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();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function buildReviewDerivedFileMap(
|
|
ReviewPack $reviewPack,
|
|
TenantReview $review,
|
|
Tenant $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
bool $includePii,
|
|
bool $includeOperations,
|
|
\Carbon\CarbonInterface $generatedAt,
|
|
): array {
|
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
$deliveryMetadata = $this->deliveryBundleMetadata(
|
|
reviewPack: $reviewPack,
|
|
review: $review,
|
|
snapshot: $snapshot,
|
|
generatedAt: $generatedAt,
|
|
);
|
|
|
|
$sections = $review->sections
|
|
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
|
->values();
|
|
|
|
$files = [
|
|
'metadata.json' => json_encode([
|
|
'version' => '1.0',
|
|
'tenant_id' => $tenant->external_id,
|
|
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
|
'generated_at' => $generatedAt->toIso8601String(),
|
|
'delivery_bundle' => $deliveryMetadata,
|
|
'tenant_review' => [
|
|
'id' => (int) $review->getKey(),
|
|
'status' => (string) $review->status,
|
|
'completeness_state' => (string) $review->completeness_state,
|
|
'published_at' => $review->published_at?->toIso8601String(),
|
|
'fingerprint' => (string) $review->fingerprint,
|
|
],
|
|
'evidence_snapshot' => [
|
|
'id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'options' => [
|
|
'include_pii' => $includePii,
|
|
'include_operations' => $includeOperations,
|
|
],
|
|
'redaction_integrity' => [
|
|
'protected_values_hidden' => true,
|
|
'note' => RedactionIntegrity::protectedValueNote(),
|
|
],
|
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
'summary.json' => json_encode($this->redactReportPayload(array_merge(
|
|
[
|
|
'tenant_review_id' => (int) $review->getKey(),
|
|
'review_status' => (string) $review->status,
|
|
'review_completeness_state' => (string) $review->completeness_state,
|
|
],
|
|
$reviewSummary,
|
|
[
|
|
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
|
],
|
|
), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
|
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
|
|
|
return [
|
|
'section_key' => (string) $section->section_key,
|
|
'title' => (string) $section->title,
|
|
'sort_order' => (int) $section->sort_order,
|
|
'required' => (bool) $section->required,
|
|
'completeness_state' => (string) $section->completeness_state,
|
|
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
|
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
|
];
|
|
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME => $this->buildExecutiveEntrypoint(
|
|
review: $review,
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
reviewSummary: $reviewSummary,
|
|
includePii: $includePii,
|
|
generatedAt: $generatedAt,
|
|
),
|
|
];
|
|
|
|
foreach ($sections as $section) {
|
|
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
|
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
$filename = sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key);
|
|
|
|
$files[$filename] = json_encode([
|
|
'title' => (string) $section->title,
|
|
'completeness_state' => (string) $section->completeness_state,
|
|
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
|
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function deliveryBundleSummary(TenantReview $review): array
|
|
{
|
|
return [
|
|
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
|
'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
|
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function deliveryBundleMetadata(
|
|
ReviewPack $reviewPack,
|
|
TenantReview $review,
|
|
EvidenceSnapshot $snapshot,
|
|
\Carbon\CarbonInterface $generatedAt,
|
|
): array {
|
|
return [
|
|
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
|
'artifact_family' => 'review_pack',
|
|
'review_pack_id' => (int) $reviewPack->getKey(),
|
|
'generated_at' => $generatedAt->toIso8601String(),
|
|
'released_review' => [
|
|
'id' => (int) $review->getKey(),
|
|
'status' => (string) $review->status,
|
|
'completeness_state' => (string) $review->completeness_state,
|
|
'published_at' => $review->published_at?->toIso8601String(),
|
|
],
|
|
'interpretation_version' => $review->controlInterpretationVersion(),
|
|
'evidence_basis' => [
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'entrypoint' => [
|
|
'file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
|
'role' => 'executive_entrypoint',
|
|
'audience' => 'executive',
|
|
'format' => 'text/markdown',
|
|
],
|
|
'appendix' => [
|
|
[
|
|
'file' => 'metadata.json',
|
|
'role' => 'bundle_metadata',
|
|
'description' => 'Structured delivery metadata and artifact role map.',
|
|
],
|
|
[
|
|
'file' => 'summary.json',
|
|
'role' => 'review_summary_appendix',
|
|
'description' => 'Structured released-review summary truth.',
|
|
],
|
|
[
|
|
'file' => 'sections.json',
|
|
'role' => 'section_detail_appendix',
|
|
'description' => 'Structured released-review section detail.',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $reviewSummary
|
|
*/
|
|
private function buildExecutiveEntrypoint(
|
|
TenantReview $review,
|
|
Tenant $tenant,
|
|
EvidenceSnapshot $snapshot,
|
|
array $reviewSummary,
|
|
bool $includePii,
|
|
\Carbon\CarbonInterface $generatedAt,
|
|
): string {
|
|
$package = is_array($reviewSummary['governance_package'] ?? null)
|
|
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
|
: [];
|
|
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
|
|
? $reviewSummary['control_interpretation']
|
|
: [];
|
|
$nonCertificationDisclosure = $this->plainText(
|
|
$controlInterpretation['non_certification_disclosure'] ?? null,
|
|
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
|
);
|
|
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
|
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
|
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
|
$governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : [];
|
|
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
|
|
|
$lines = [
|
|
'# Executive summary',
|
|
'',
|
|
'Tenant: '.$this->plainText($tenantName, '[REDACTED]'),
|
|
'Released review: #'.((int) $review->getKey()),
|
|
'Review status: '.$this->plainText($review->status, 'unknown'),
|
|
'Generated at: '.$generatedAt->toIso8601String(),
|
|
'',
|
|
'## Executive story',
|
|
'',
|
|
$this->plainText($package['executive_summary'] ?? null, 'No executive summary is available for this released review.'),
|
|
'',
|
|
'## Evidence basis',
|
|
'',
|
|
$this->plainText(
|
|
$package['evidence_basis_summary'] ?? null,
|
|
sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state),
|
|
),
|
|
'',
|
|
'## Key findings',
|
|
'',
|
|
...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'),
|
|
'',
|
|
'## Accepted risks',
|
|
'',
|
|
...$this->entryBullets($acceptedRisks, 'No accepted risks are listed for this released review.'),
|
|
'',
|
|
'## Governance decisions requiring awareness',
|
|
'',
|
|
...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'),
|
|
'',
|
|
'## Next actions',
|
|
'',
|
|
...$this->textBullets($nextActions, 'No next action is listed for this released review.'),
|
|
'',
|
|
'## Non-certification disclosure',
|
|
'',
|
|
$nonCertificationDisclosure,
|
|
'',
|
|
'## Structured auditor appendix',
|
|
'',
|
|
'This executive entrypoint is the first file to read. The structured auditor appendix remains available in metadata.json, summary.json, and sections.json.',
|
|
'',
|
|
];
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $entries
|
|
* @return list<string>
|
|
*/
|
|
private function entryBullets(array $entries, string $emptyText): array
|
|
{
|
|
if ($entries === []) {
|
|
return ['- '.$emptyText];
|
|
}
|
|
|
|
return collect($entries)
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->map(function (array $entry): string {
|
|
$title = $this->plainText($entry['title'] ?? null, 'Entry');
|
|
$summary = $this->plainText($entry['summary'] ?? null, '');
|
|
|
|
return $summary === '' ? '- '.$title : '- '.$title.' - '.$summary;
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $entries
|
|
* @return list<string>
|
|
*/
|
|
private function textBullets(array $entries, string $emptyText): array
|
|
{
|
|
$bullets = collect($entries)
|
|
->filter(static fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')
|
|
->map(fn (string $entry): string => '- '.$this->plainText($entry, ''))
|
|
->values()
|
|
->all();
|
|
|
|
return $bullets === [] ? ['- '.$emptyText] : $bullets;
|
|
}
|
|
|
|
private function plainText(mixed $value, string $fallback): string
|
|
{
|
|
if (! is_scalar($value) && $value !== null) {
|
|
return $fallback;
|
|
}
|
|
|
|
$text = preg_replace('/\s+/', ' ', trim((string) $value));
|
|
|
|
return is_string($text) && $text !== '' ? $text : $fallback;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|