Automatisch erstellt: Commit aller Änderungen in Branch 260-governance-service-packaging-session-1777640889. Bitte prüfen und mergen. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #315
338 lines
14 KiB
PHP
338 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantReviews;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\TenantReview;
|
|
use App\Support\TenantReviewStatus;
|
|
|
|
final class TenantReviewComposer
|
|
{
|
|
public function __construct(
|
|
private readonly TenantReviewFingerprint $fingerprint,
|
|
private readonly TenantReviewSectionFactory $sectionFactory,
|
|
private readonly TenantReviewReadinessGate $readinessGate,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* fingerprint: string,
|
|
* completeness_state: string,
|
|
* status: string,
|
|
* summary: array<string, mixed>,
|
|
* sections: list<array<string, mixed>>
|
|
* }
|
|
*/
|
|
public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null): array
|
|
{
|
|
$tenant = $snapshot->tenant;
|
|
|
|
if ($tenant === null) {
|
|
throw new \RuntimeException('Evidence snapshot tenant is required for review composition.');
|
|
}
|
|
|
|
$sections = $this->sectionFactory->make($snapshot);
|
|
$blockers = $this->readinessGate->blockersForSections($sections);
|
|
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
|
|
$completeness = $this->readinessGate->completenessForSections($sections);
|
|
$status = $this->readinessGate->statusForSections($sections);
|
|
$executiveSummarySection = collect($sections)
|
|
->firstWhere('section_key', 'executive_summary');
|
|
$controlInterpretationSection = collect($sections)
|
|
->firstWhere('section_key', 'control_interpretation');
|
|
$openRisksSection = collect($sections)
|
|
->firstWhere('section_key', 'open_risks');
|
|
$acceptedRisksSection = collect($sections)
|
|
->firstWhere('section_key', 'accepted_risks');
|
|
$operationsSection = collect($sections)
|
|
->firstWhere('section_key', 'operations_health');
|
|
|
|
if ($review instanceof TenantReview && $review->isPublished()) {
|
|
$status = TenantReviewStatus::Published;
|
|
}
|
|
|
|
return [
|
|
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
|
|
'completeness_state' => $completeness->value,
|
|
'status' => $status->value,
|
|
'summary' => [
|
|
'evidence_basis' => [
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
|
|
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'section_count' => count($sections),
|
|
'section_state_counts' => $sectionStateCounts,
|
|
'publish_blockers' => $blockers,
|
|
'has_ready_export' => false,
|
|
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
|
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
|
|
? data_get($sections, '0.summary_payload.finding_outcomes')
|
|
: [],
|
|
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
|
|
? data_get($sections, '0.summary_payload.finding_report_buckets')
|
|
: [],
|
|
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
|
|
? data_get($sections, '0.summary_payload.canonical_controls')
|
|
: [],
|
|
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
|
|
? array_merge(
|
|
data_get($controlInterpretationSection, 'summary_payload'),
|
|
[
|
|
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
|
|
? data_get($controlInterpretationSection, 'render_payload.entries')
|
|
: [],
|
|
],
|
|
)
|
|
: [],
|
|
'report_count' => 2,
|
|
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
|
|
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
|
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
|
|
'governance_package' => $this->governancePackageSummary(
|
|
snapshot: $snapshot,
|
|
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
|
|
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
|
|
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
|
|
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
|
|
),
|
|
'last_composed_at' => now()->toIso8601String(),
|
|
],
|
|
'sections' => $sections,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $executiveSummarySection
|
|
* @param array<string, mixed> $controlInterpretationSection
|
|
* @param array<string, mixed> $openRisksSection
|
|
* @param array<string, mixed> $acceptedRisksSection
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function governancePackageSummary(
|
|
EvidenceSnapshot $snapshot,
|
|
array $executiveSummarySection,
|
|
array $controlInterpretationSection,
|
|
array $openRisksSection,
|
|
array $acceptedRisksSection,
|
|
): array {
|
|
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
|
|
? $executiveSummarySection['summary_payload']
|
|
: [];
|
|
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
|
|
? $executiveSummarySection['render_payload']
|
|
: [];
|
|
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
|
|
? $controlInterpretationSection['summary_payload']
|
|
: [];
|
|
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->take(3)
|
|
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
|
|
->values()
|
|
->all();
|
|
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
|
|
->values();
|
|
$governanceDecisionEntries = $acceptedRiskEntries
|
|
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
|
|
->values();
|
|
$stableAcceptedRiskEntries = $acceptedRiskEntries
|
|
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
|
|
->values();
|
|
$governanceDecisions = $governanceDecisionEntries
|
|
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'delivery_artifact_family' => 'review_pack',
|
|
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
|
|
? $controlInterpretationSummary['version_key']
|
|
: null,
|
|
'executive_summary' => $this->governancePackageExecutiveSummary(
|
|
executiveSummaryPayload: $executiveSummaryPayload,
|
|
executiveRenderPayload: $executiveRenderPayload,
|
|
controlInterpretationSummary: $controlInterpretationSummary,
|
|
acceptedRiskCount: $acceptedRiskEntries->count(),
|
|
),
|
|
'top_findings' => $openRiskEntries,
|
|
'accepted_risks' => $stableAcceptedRiskEntries->all(),
|
|
'governance_decisions' => $governanceDecisions,
|
|
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
|
|
snapshot: $snapshot,
|
|
controlInterpretationSummary: $controlInterpretationSummary,
|
|
),
|
|
'supporting_artifact_links' => [
|
|
[
|
|
'artifact_family' => 'evidence_snapshot',
|
|
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
|
|
'purpose' => 'evidence_basis',
|
|
],
|
|
[
|
|
'artifact_family' => 'review_pack',
|
|
'artifact_key' => 'review_pack:current_export',
|
|
'purpose' => 'stakeholder_delivery',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function packageFindingEntry(array $entry): array
|
|
{
|
|
return [
|
|
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
|
|
'title' => $this->entryTitle($entry, 'Open finding'),
|
|
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
|
|
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
|
|
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function packageAcceptedRiskEntry(array $entry): array
|
|
{
|
|
return [
|
|
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
|
|
'title' => $this->entryTitle($entry, 'Accepted risk'),
|
|
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
|
|
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
|
|
'owner_label' => $this->ownerLabel($entry),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private function requiresGovernanceDecisionFollowUp(array $entry): bool
|
|
{
|
|
return in_array((string) ($entry['governance_state'] ?? ''), [
|
|
'expired_exception',
|
|
'revoked_exception',
|
|
'risk_accepted_without_valid_exception',
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function packageGovernanceDecisionEntry(array $entry): array
|
|
{
|
|
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
|
|
|
|
return [
|
|
'finding_id' => $entry['finding_id'] ?? null,
|
|
'title' => $entry['title'] ?? 'Governance decision',
|
|
'governance_state' => $governanceState,
|
|
'summary' => match ($governanceState) {
|
|
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
|
|
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
|
|
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
|
|
default => 'This governance decision needs follow-up before stakeholder delivery.',
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $executiveSummaryPayload
|
|
* @param array<string, mixed> $executiveRenderPayload
|
|
* @param array<string, mixed> $controlInterpretationSummary
|
|
*/
|
|
private function governancePackageExecutiveSummary(
|
|
array $executiveSummaryPayload,
|
|
array $executiveRenderPayload,
|
|
array $controlInterpretationSummary,
|
|
int $acceptedRiskCount,
|
|
): string {
|
|
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
|
|
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
|
|
->values();
|
|
|
|
if ($highlights->isNotEmpty()) {
|
|
return (string) $highlights->first();
|
|
}
|
|
|
|
return sprintf(
|
|
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
|
|
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
|
|
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
|
|
$acceptedRiskCount,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $controlInterpretationSummary
|
|
*/
|
|
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
|
|
{
|
|
return sprintf(
|
|
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
|
|
(int) $snapshot->getKey(),
|
|
(string) $snapshot->completeness_state,
|
|
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private function entryTitle(array $entry, string $fallback): string
|
|
{
|
|
foreach (['title', 'name', 'finding_title'] as $key) {
|
|
$value = $entry[$key] ?? null;
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private function entrySummary(array $entry, string $fallback): string
|
|
{
|
|
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
|
|
$value = $entry[$key] ?? null;
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private function ownerLabel(array $entry): ?string
|
|
{
|
|
$owner = $entry['owner'] ?? null;
|
|
|
|
if (is_array($owner)) {
|
|
$name = $owner['name'] ?? null;
|
|
|
|
if (is_string($name) && trim($name) !== '') {
|
|
return $name;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|