Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m7s
Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage.
322 lines
13 KiB
PHP
322 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ReviewPacks;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Workspace;
|
|
use InvalidArgumentException;
|
|
|
|
final class ManagementReportPdfPayloadBuilder
|
|
{
|
|
public const string PROFILE = ReportProfileRegistry::CUSTOMER_EXECUTIVE;
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function build(ReviewPack $reviewPack): array
|
|
{
|
|
$reviewPack->loadMissing([
|
|
'tenant.workspace',
|
|
'environmentReview.sections',
|
|
'environmentReview.evidenceSnapshot',
|
|
'environmentReview.currentExportReviewPack',
|
|
]);
|
|
|
|
$tenant = $reviewPack->tenant;
|
|
$workspace = $tenant?->workspace;
|
|
$review = $reviewPack->environmentReview;
|
|
|
|
if (! $tenant instanceof ManagedEnvironment || ! $workspace instanceof Workspace || ! $review instanceof EnvironmentReview) {
|
|
throw new InvalidArgumentException('Management report PDF requires a tenant, workspace, and released review.');
|
|
}
|
|
|
|
if ((int) ($review->current_export_review_pack_id ?? 0) !== (int) $reviewPack->getKey()) {
|
|
throw new InvalidArgumentException('Management report PDF can only be generated from the current review pack.');
|
|
}
|
|
|
|
$disclosureDecision = self::customerExecutiveDisclosureDecision($reviewPack);
|
|
$profile = is_array($disclosureDecision['profile'] ?? null) ? $disclosureDecision['profile'] : [];
|
|
$readiness = is_array($disclosureDecision['readiness'] ?? null) ? $disclosureDecision['readiness'] : [];
|
|
$disclosure = is_array($disclosureDecision['disclosure'] ?? null) ? $disclosureDecision['disclosure'] : [];
|
|
|
|
if ((string) ($disclosureDecision['reason_code'] ?? '') === 'management_report_pdf_profile_invalid') {
|
|
throw new InvalidArgumentException('Management report PDF requires the customer executive profile without fallback.');
|
|
}
|
|
|
|
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness);
|
|
|
|
if ((bool) ($disclosureDecision['is_blocked'] ?? false)) {
|
|
throw new InvalidArgumentException('Management report PDF generation is blocked by the customer-facing disclosure policy.');
|
|
}
|
|
|
|
$payload = [
|
|
'title' => 'TenantPilot Management Report',
|
|
'report_type' => 'management_report_pdf',
|
|
'profile' => self::PROFILE,
|
|
'profile_label' => (string) ($profile['label'] ?? 'Customer executive'),
|
|
'audience_label' => (string) ($profile['audience_label'] ?? 'Customer executive'),
|
|
'classification' => 'Customer-facing management report',
|
|
'workspace' => [
|
|
'id' => (int) $workspace->getKey(),
|
|
'name' => (string) $workspace->name,
|
|
],
|
|
'managed_environment' => [
|
|
'id' => (int) $tenant->getKey(),
|
|
'name' => (string) $tenant->name,
|
|
'external_id' => (string) $tenant->external_id,
|
|
],
|
|
'provenance' => [
|
|
'environment_review_id' => (int) $review->getKey(),
|
|
'review_pack_id' => (int) $reviewPack->getKey(),
|
|
'review_status' => (string) $review->status,
|
|
'review_fingerprint' => (string) $review->fingerprint,
|
|
'review_pack_fingerprint' => (string) $reviewPack->fingerprint,
|
|
'review_pack_sha256' => (string) $reviewPack->sha256,
|
|
'generated_at' => now()->toIso8601String(),
|
|
],
|
|
'chapters' => [
|
|
$this->chapter('cover', 'Cover', [
|
|
'environment' => (string) $tenant->name,
|
|
'workspace' => (string) $workspace->name,
|
|
'profile' => (string) ($profile['label'] ?? 'Customer executive'),
|
|
'classification' => 'Customer-facing management report',
|
|
'generated_at' => now()->toFormattedDateString(),
|
|
]),
|
|
$this->chapter('executive_summary', 'Executive summary', [
|
|
'summary' => $this->firstString(
|
|
data_get($reviewPack->summary, 'governance_package.executive_summary'),
|
|
data_get($review->summary, 'governance_package.executive_summary'),
|
|
data_get($review->summary, 'executive_summary'),
|
|
__('localization.review.rendered_report_summary_fallback'),
|
|
),
|
|
]),
|
|
$this->chapter('governance_posture', 'Governance posture', [
|
|
'state' => (string) ($guidance['label'] ?? 'Unavailable'),
|
|
'boundary' => (string) ($guidance['boundary_label'] ?? 'Needs review'),
|
|
'reason' => (string) ($guidance['primary_reason'] ?? ''),
|
|
'impact' => (string) ($guidance['impact'] ?? ''),
|
|
]),
|
|
$this->chapter('key_decisions', 'Key decisions', [
|
|
'items' => $this->listItems(
|
|
data_get($reviewPack->summary, 'decision_summary.entries')
|
|
?? data_get($review->summary, 'decision_summary.entries')
|
|
?? data_get($review->summary, 'governance_decisions'),
|
|
['title', 'label', 'decision', 'summary', 'rationale'],
|
|
),
|
|
]),
|
|
$this->chapter('top_risks', 'Top risks and findings', [
|
|
'items' => $this->listItems(
|
|
data_get($reviewPack->summary, 'top_findings')
|
|
?? data_get($review->summary, 'top_findings')
|
|
?? data_get($review->summary, 'finding_report_buckets.high'),
|
|
['title', 'label', 'severity', 'summary', 'recommendation'],
|
|
5,
|
|
),
|
|
]),
|
|
$this->chapter('accepted_risks', 'Accepted risks', [
|
|
'items' => $this->listItems(
|
|
data_get($reviewPack->summary, 'risk_acceptance')
|
|
?? data_get($review->summary, 'risk_acceptance'),
|
|
['title', 'label', 'risk', 'summary', 'accepted_until'],
|
|
5,
|
|
),
|
|
]),
|
|
$this->chapter('evidence_basis', 'Evidence basis', [
|
|
'evidence_snapshot_id' => $review->evidence_snapshot_id !== null ? (int) $review->evidence_snapshot_id : null,
|
|
'evidence_completeness' => (string) ($readiness['evidence_completeness_state'] ?? 'unknown'),
|
|
'review_pack_generated_at' => $reviewPack->generated_at?->toIso8601String(),
|
|
'review_pack_sha256' => (string) $reviewPack->sha256,
|
|
]),
|
|
$this->chapter('limitations', 'Limitations and disclosures', [
|
|
'limitations' => $this->listItems($guidance['limitations'] ?? [], ['label', 'reason', 'severity']),
|
|
'warnings' => $this->listItems($disclosure['warnings'], ['label', 'summary']),
|
|
'mandatory_disclosures' => $this->listItems($disclosure['mandatory_disclosures'], ['label', 'summary', 'proof_state']),
|
|
]),
|
|
$this->chapter('next_actions', 'Next actions', [
|
|
'items' => $this->listItems(
|
|
data_get($reviewPack->summary, 'recommended_next_actions')
|
|
?? data_get($review->summary, 'recommended_next_actions')
|
|
?? [],
|
|
['title', 'label', 'summary', 'owner', 'due'],
|
|
),
|
|
]),
|
|
$this->chapter('method_summary', 'Method summary', [
|
|
'summary' => 'Generated from the current customer-safe Review Pack and released review state. Raw evidence payloads, secrets, and Graph API responses are not included in this management PDF.',
|
|
]),
|
|
],
|
|
];
|
|
|
|
return $this->sanitize($payload);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* is_blocked: bool,
|
|
* reason_code: ?string,
|
|
* reason: ?string,
|
|
* profile: array<string, mixed>,
|
|
* readiness: array<string, mixed>,
|
|
* disclosure: array<string, mixed>
|
|
* }
|
|
*/
|
|
public static function customerExecutiveDisclosureDecision(ReviewPack $reviewPack): array
|
|
{
|
|
$reviewPack->loadMissing([
|
|
'environmentReview.sections',
|
|
'environmentReview.evidenceSnapshot',
|
|
'environmentReview.currentExportReviewPack',
|
|
]);
|
|
|
|
$review = $reviewPack->environmentReview;
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
throw new InvalidArgumentException('Management report PDF requires a released review.');
|
|
}
|
|
|
|
$profile = ReportProfileRegistry::resolve(self::PROFILE, self::PROFILE);
|
|
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
|
|
|
|
if ((string) ($profile['effective_key'] ?? '') !== self::PROFILE || (bool) ($profile['is_fallback'] ?? false)) {
|
|
return [
|
|
'is_blocked' => true,
|
|
'reason_code' => 'management_report_pdf_profile_invalid',
|
|
'reason' => 'Management report PDF requires the customer executive profile without fallback.',
|
|
'profile' => $profile,
|
|
'readiness' => $readiness,
|
|
'disclosure' => [
|
|
'blocking_reasons' => [],
|
|
'warnings' => [],
|
|
'mandatory_disclosures' => [],
|
|
'proof_states' => [],
|
|
],
|
|
];
|
|
}
|
|
|
|
$metadata = [
|
|
'non_certification_disclosure' => data_get($reviewPack->summary, 'control_interpretation.non_certification_disclosure')
|
|
?? data_get($review->summary, 'control_interpretation.non_certification_disclosure'),
|
|
];
|
|
$disclosure = ReportDisclosurePolicy::evaluate($profile, $readiness, $metadata);
|
|
$blockingReason = $disclosure['blocking_reasons'][0] ?? null;
|
|
|
|
if (is_array($blockingReason)) {
|
|
$label = trim((string) ($blockingReason['label'] ?? ''));
|
|
$summary = trim((string) ($blockingReason['summary'] ?? ''));
|
|
|
|
return [
|
|
'is_blocked' => true,
|
|
'reason_code' => (string) ($blockingReason['key'] ?? 'disclosure_blocked'),
|
|
'reason' => $label !== '' && $summary !== '' ? "{$label}: {$summary}" : ($summary !== '' ? $summary : $label),
|
|
'profile' => $profile,
|
|
'readiness' => $readiness,
|
|
'disclosure' => $disclosure,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'is_blocked' => false,
|
|
'reason_code' => null,
|
|
'reason' => null,
|
|
'profile' => $profile,
|
|
'readiness' => $readiness,
|
|
'disclosure' => $disclosure,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $content
|
|
* @return array{key:string,title:string,content:array<string, mixed>}
|
|
*/
|
|
private function chapter(string $key, string $title, array $content): array
|
|
{
|
|
return compact('key', 'title', 'content');
|
|
}
|
|
|
|
private function firstString(mixed ...$values): string
|
|
{
|
|
foreach ($values as $value) {
|
|
if (is_scalar($value) && trim((string) $value) !== '') {
|
|
return trim((string) $value);
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $fields
|
|
* @return list<array<string, string>>
|
|
*/
|
|
private function listItems(mixed $items, array $fields, int $limit = 8): array
|
|
{
|
|
if (! is_iterable($items)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($items as $item) {
|
|
if (is_scalar($item)) {
|
|
$normalized[] = ['summary' => trim((string) $item)];
|
|
} elseif (is_array($item)) {
|
|
$entry = [];
|
|
|
|
foreach ($fields as $field) {
|
|
$value = data_get($item, $field);
|
|
|
|
if (is_scalar($value) && trim((string) $value) !== '') {
|
|
$entry[$field] = trim((string) $value);
|
|
}
|
|
}
|
|
|
|
if ($entry !== []) {
|
|
$normalized[] = $entry;
|
|
}
|
|
}
|
|
|
|
if (count($normalized) >= $limit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function sanitize(mixed $value): mixed
|
|
{
|
|
if (is_array($value)) {
|
|
$sanitized = [];
|
|
|
|
foreach ($value as $key => $item) {
|
|
$sanitized[$key] = $this->sanitize($item);
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
if (! is_scalar($value) && $value !== null) {
|
|
return null;
|
|
}
|
|
|
|
$text = preg_replace('/\s+/', ' ', trim((string) $value));
|
|
|
|
if (! is_string($text)) {
|
|
return null;
|
|
}
|
|
|
|
if ($text === '') {
|
|
return $text;
|
|
}
|
|
|
|
if (preg_match('/SQLSTATE|Bearer\s+|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password/i', $text) === 1) {
|
|
return '[redacted]';
|
|
}
|
|
|
|
return mb_substr(strip_tags($text), 0, 1200);
|
|
}
|
|
}
|