feat: implement decision register summary in environment review packs
This commit is contained in:
parent
ca30ca95bf
commit
4fc8308540
@ -279,6 +279,9 @@ private function executeReviewDerivedGeneration(
|
|||||||
|
|
||||||
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
||||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||||
|
$governancePackage = is_array($reviewSummary['governance_package'] ?? null)
|
||||||
|
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
||||||
|
: [];
|
||||||
$summary = [
|
$summary = [
|
||||||
'environment_review_id' => (int) $review->getKey(),
|
'environment_review_id' => (int) $review->getKey(),
|
||||||
'review_status' => (string) $review->status,
|
'review_status' => (string) $review->status,
|
||||||
@ -289,6 +292,7 @@ private function executeReviewDerivedGeneration(
|
|||||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
||||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
||||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
||||||
|
'governance_package' => $governancePackage,
|
||||||
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||||
'evidence_resolution' => [
|
'evidence_resolution' => [
|
||||||
'outcome' => 'resolved',
|
'outcome' => 'resolved',
|
||||||
@ -541,7 +545,24 @@ private function redactReportPayload(array $payload, bool $includePii): array
|
|||||||
*/
|
*/
|
||||||
private function redactArrayPii(array $data): array
|
private function redactArrayPii(array $data): array
|
||||||
{
|
{
|
||||||
$piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail'];
|
$piiKeys = [
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'userPrincipalName',
|
||||||
|
'user_principal_name',
|
||||||
|
'email',
|
||||||
|
'mail',
|
||||||
|
'tenant_name',
|
||||||
|
'tenant_label',
|
||||||
|
'customer_name',
|
||||||
|
'owner_label',
|
||||||
|
'owner_name',
|
||||||
|
'actor_label',
|
||||||
|
'actor_name',
|
||||||
|
'initiator_name',
|
||||||
|
'requested_by',
|
||||||
|
'approved_by',
|
||||||
|
];
|
||||||
|
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
if (is_string($key) && in_array($key, $piiKeys, true)) {
|
if (is_string($key) && in_array($key, $piiKeys, true)) {
|
||||||
@ -825,7 +846,10 @@ private function buildExecutiveEntrypoint(
|
|||||||
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
||||||
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
||||||
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
||||||
$governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : [];
|
$decisionSummary = is_array($package['decision_summary'] ?? null) ? $package['decision_summary'] : [];
|
||||||
|
$governanceDecisions = is_array($decisionSummary['entries'] ?? null)
|
||||||
|
? $decisionSummary['entries']
|
||||||
|
: (is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : []);
|
||||||
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
@ -857,7 +881,7 @@ private function buildExecutiveEntrypoint(
|
|||||||
'',
|
'',
|
||||||
'## Governance decisions requiring awareness',
|
'## Governance decisions requiring awareness',
|
||||||
'',
|
'',
|
||||||
...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'),
|
...$this->decisionSummaryLines($decisionSummary, $governanceDecisions),
|
||||||
'',
|
'',
|
||||||
'## Next actions',
|
'## Next actions',
|
||||||
'',
|
'',
|
||||||
@ -876,6 +900,35 @@ private function buildExecutiveEntrypoint(
|
|||||||
return implode("\n", $lines);
|
return implode("\n", $lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decisionSummary
|
||||||
|
* @param array<int, mixed> $entries
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function decisionSummaryLines(array $decisionSummary, array $entries): array
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
$summary = $this->plainText($decisionSummary['summary'] ?? null, '');
|
||||||
|
$nextAction = $this->plainText($decisionSummary['next_action'] ?? null, '');
|
||||||
|
|
||||||
|
if ($summary !== '') {
|
||||||
|
$lines[] = $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($nextAction !== '') {
|
||||||
|
$lines[] = 'Next action: '.$nextAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lines !== []) {
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$lines,
|
||||||
|
...$this->entryBullets($entries, 'No governance decisions require awareness in this released review.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, mixed> $entries
|
* @param array<int, mixed> $entries
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
|
|||||||
@ -148,6 +148,11 @@ private function governancePackageSummary(
|
|||||||
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
|
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
$decisionSummary = $this->packageDecisionSummary(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
acceptedRisksSection: $acceptedRisksSection,
|
||||||
|
governanceDecisions: $governanceDecisions,
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'delivery_artifact_family' => 'review_pack',
|
'delivery_artifact_family' => 'review_pack',
|
||||||
@ -163,6 +168,7 @@ private function governancePackageSummary(
|
|||||||
'top_findings' => $openRiskEntries,
|
'top_findings' => $openRiskEntries,
|
||||||
'accepted_risks' => $stableAcceptedRiskEntries->all(),
|
'accepted_risks' => $stableAcceptedRiskEntries->all(),
|
||||||
'governance_decisions' => $governanceDecisions,
|
'governance_decisions' => $governanceDecisions,
|
||||||
|
'decision_summary' => $decisionSummary,
|
||||||
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
|
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
|
||||||
snapshot: $snapshot,
|
snapshot: $snapshot,
|
||||||
controlInterpretationSummary: $controlInterpretationSummary,
|
controlInterpretationSummary: $controlInterpretationSummary,
|
||||||
@ -236,15 +242,118 @@ private function packageGovernanceDecisionEntry(array $entry): array
|
|||||||
'finding_id' => $entry['finding_id'] ?? null,
|
'finding_id' => $entry['finding_id'] ?? null,
|
||||||
'title' => $entry['title'] ?? 'Governance decision',
|
'title' => $entry['title'] ?? 'Governance decision',
|
||||||
'governance_state' => $governanceState,
|
'governance_state' => $governanceState,
|
||||||
|
'awareness_reason' => $this->governanceDecisionAwarenessReason($governanceState),
|
||||||
'summary' => match ($governanceState) {
|
'summary' => match ($governanceState) {
|
||||||
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
|
'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.',
|
'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.',
|
'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.',
|
default => 'This governance decision needs follow-up before stakeholder delivery.',
|
||||||
},
|
},
|
||||||
|
'next_action' => $this->governanceDecisionNextAction($governanceState),
|
||||||
|
'evidence_basis' => 'Included in the anchored released-review evidence basis.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $acceptedRisksSection
|
||||||
|
* @param list<array<string, mixed>> $governanceDecisions
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function packageDecisionSummary(
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
array $acceptedRisksSection,
|
||||||
|
array $governanceDecisions,
|
||||||
|
): array {
|
||||||
|
$totalCount = count($governanceDecisions);
|
||||||
|
$evidenceState = is_string($acceptedRisksSection['completeness_state'] ?? null)
|
||||||
|
? (string) $acceptedRisksSection['completeness_state']
|
||||||
|
: (string) $snapshot->completeness_state;
|
||||||
|
$decisionDataState = $totalCount > 0 || $this->decisionEvidenceIsAvailable($evidenceState)
|
||||||
|
? 'available'
|
||||||
|
: 'incomplete';
|
||||||
|
$status = match (true) {
|
||||||
|
$totalCount > 0 => 'requires_awareness',
|
||||||
|
$decisionDataState === 'incomplete' => 'unavailable',
|
||||||
|
default => 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'customer_safe' => true,
|
||||||
|
'status' => $status,
|
||||||
|
'decision_data_state' => $decisionDataState,
|
||||||
|
'evidence_state' => $evidenceState,
|
||||||
|
'total_count' => $totalCount,
|
||||||
|
'requires_awareness' => $totalCount > 0,
|
||||||
|
'summary' => $this->decisionSummaryText($status, $totalCount),
|
||||||
|
'empty_state' => $this->decisionSummaryEmptyState($status),
|
||||||
|
'next_action' => $this->decisionSummaryNextAction($status),
|
||||||
|
'evidence_basis' => sprintf(
|
||||||
|
'Anchored to evidence snapshot #%d with %s decision-evidence completeness.',
|
||||||
|
(int) $snapshot->getKey(),
|
||||||
|
$evidenceState,
|
||||||
|
),
|
||||||
|
'source_section_state' => is_string($acceptedRisksSection['completeness_state'] ?? null)
|
||||||
|
? $acceptedRisksSection['completeness_state']
|
||||||
|
: null,
|
||||||
|
'entries' => $governanceDecisions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionEvidenceIsAvailable(string $evidenceState): bool
|
||||||
|
{
|
||||||
|
return in_array($evidenceState, ['complete'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionSummaryText(string $status, int $totalCount): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'requires_awareness' => sprintf(
|
||||||
|
'%d governance decision%s require%s customer awareness before relying on this released review.',
|
||||||
|
$totalCount,
|
||||||
|
$totalCount === 1 ? '' : 's',
|
||||||
|
$totalCount === 1 ? 's' : '',
|
||||||
|
),
|
||||||
|
'unavailable' => 'Decision evidence is incomplete for this released review; no customer-aware decisions can be confirmed from the current evidence basis.',
|
||||||
|
default => 'No governance decisions require customer awareness in this released review.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionSummaryEmptyState(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'unavailable' => 'Decision evidence is incomplete in the current released-review basis.',
|
||||||
|
'requires_awareness' => '',
|
||||||
|
default => 'No governance decisions require awareness in this released review.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionSummaryNextAction(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'requires_awareness' => 'Review the accepted-risk decision basis before customer delivery.',
|
||||||
|
'unavailable' => 'Open the review evidence before treating the decision register summary as complete.',
|
||||||
|
default => 'No customer action is needed for Decision Register follow-up from this review.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceDecisionAwarenessReason(string $governanceState): string
|
||||||
|
{
|
||||||
|
return match ($governanceState) {
|
||||||
|
'expired_exception' => 'The accepted-risk approval has expired and needs customer awareness before the review is relied on.',
|
||||||
|
'revoked_exception' => 'The accepted-risk approval was revoked and needs customer awareness before the review is relied on.',
|
||||||
|
'risk_accepted_without_valid_exception' => 'The accepted risk has no valid governance backing in the released review evidence.',
|
||||||
|
default => 'This accepted-risk governance decision needs customer awareness before the review is relied on.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceDecisionNextAction(string $governanceState): string
|
||||||
|
{
|
||||||
|
return match ($governanceState) {
|
||||||
|
'expired_exception', 'revoked_exception', 'risk_accepted_without_valid_exception' => 'Confirm whether this accepted risk should be renewed, remediated, or removed before relying on the review.',
|
||||||
|
default => 'Confirm the accepted-risk decision before relying on the review.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $executiveSummaryPayload
|
* @param array<string, mixed> $executiveSummaryPayload
|
||||||
* @param array<string, mixed> $executiveRenderPayload
|
* @param array<string, mixed> $executiveRenderPayload
|
||||||
|
|||||||
@ -50,8 +50,8 @@ public function collect(ManagedEnvironment $tenant): array
|
|||||||
'finding_type' => (string) $finding->finding_type,
|
'finding_type' => (string) $finding->finding_type,
|
||||||
'severity' => (string) $finding->severity,
|
'severity' => (string) $finding->severity,
|
||||||
'status' => (string) $finding->status,
|
'status' => (string) $finding->status,
|
||||||
'title' => $finding->title,
|
'title' => $finding->resolvedSubjectDisplayName(),
|
||||||
'description' => $finding->description,
|
'description' => null,
|
||||||
'created_at' => $finding->created_at?->toIso8601String(),
|
'created_at' => $finding->created_at?->toIso8601String(),
|
||||||
'updated_at' => $finding->updated_at?->toIso8601String(),
|
'updated_at' => $finding->updated_at?->toIso8601String(),
|
||||||
'verification_state' => $outcome['verification_state'],
|
'verification_state' => $outcome['verification_state'],
|
||||||
|
|||||||
@ -371,6 +371,7 @@
|
|||||||
'download_governance_package' => 'Governance-Paket herunterladen',
|
'download_governance_package' => 'Governance-Paket herunterladen',
|
||||||
'governance_package' => 'Governance-Paket',
|
'governance_package' => 'Governance-Paket',
|
||||||
'governance_decisions' => 'Governance-Entscheidungen',
|
'governance_decisions' => 'Governance-Entscheidungen',
|
||||||
|
'governance_decisions_requiring_awareness' => 'Governance-Entscheidungen mit Aufmerksamkeitsbedarf',
|
||||||
'governance_package_delivery_note' => 'Dieses Governance-Paket wird über das aktuelle Export-Review-Pack des veröffentlichten Reviews ausgeliefert.',
|
'governance_package_delivery_note' => 'Dieses Governance-Paket wird über das aktuelle Export-Review-Pack des veröffentlichten Reviews ausgeliefert.',
|
||||||
'executive_entrypoint' => 'Executive-Einstieg',
|
'executive_entrypoint' => 'Executive-Einstieg',
|
||||||
'executive_entrypoint_description' => 'Beginnen Sie im heruntergeladenen Paket mit executive-summary.md.',
|
'executive_entrypoint_description' => 'Beginnen Sie im heruntergeladenen Paket mit executive-summary.md.',
|
||||||
|
|||||||
@ -371,6 +371,7 @@
|
|||||||
'download_governance_package' => 'Download governance package',
|
'download_governance_package' => 'Download governance package',
|
||||||
'governance_package' => 'Governance package',
|
'governance_package' => 'Governance package',
|
||||||
'governance_decisions' => 'Governance decisions',
|
'governance_decisions' => 'Governance decisions',
|
||||||
|
'governance_decisions_requiring_awareness' => 'Governance decisions requiring awareness',
|
||||||
'governance_package_delivery_note' => 'This governance package is delivered through the current export review pack for the released review.',
|
'governance_package_delivery_note' => 'This governance package is delivered through the current export review pack for the released review.',
|
||||||
'executive_entrypoint' => 'Executive entrypoint',
|
'executive_entrypoint' => 'Executive entrypoint',
|
||||||
'executive_entrypoint_description' => 'Start with executive-summary.md in the downloaded package.',
|
'executive_entrypoint_description' => 'Start with executive-summary.md in the downloaded package.',
|
||||||
|
|||||||
@ -16,7 +16,13 @@
|
|||||||
$packageAvailability = is_array($governancePackage['availability'] ?? null) ? $governancePackage['availability'] : [];
|
$packageAvailability = is_array($governancePackage['availability'] ?? null) ? $governancePackage['availability'] : [];
|
||||||
$packageTopFindings = is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [];
|
$packageTopFindings = is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [];
|
||||||
$packageAcceptedRisks = is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [];
|
$packageAcceptedRisks = is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [];
|
||||||
$packageGovernanceDecisions = is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : [];
|
$packageDecisionSummary = is_array($governancePackage['decision_summary'] ?? null) ? $governancePackage['decision_summary'] : [];
|
||||||
|
$packageGovernanceDecisions = is_array($packageDecisionSummary['entries'] ?? null)
|
||||||
|
? $packageDecisionSummary['entries']
|
||||||
|
: (is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : []);
|
||||||
|
$packageDecisionSummaryText = is_string($packageDecisionSummary['summary'] ?? null) ? trim((string) $packageDecisionSummary['summary']) : null;
|
||||||
|
$packageDecisionNextAction = is_string($packageDecisionSummary['next_action'] ?? null) ? trim((string) $packageDecisionSummary['next_action']) : null;
|
||||||
|
$packageDecisionEmptyState = is_string($packageDecisionSummary['empty_state'] ?? null) ? trim((string) $packageDecisionSummary['empty_state']) : null;
|
||||||
$controlControls = is_array($controlInterpretation['controls'] ?? null) ? $controlInterpretation['controls'] : [];
|
$controlControls = is_array($controlInterpretation['controls'] ?? null) ? $controlInterpretation['controls'] : [];
|
||||||
$controlVersion = is_string($controlInterpretation['version_key'] ?? null) ? $controlInterpretation['version_key'] : null;
|
$controlVersion = is_string($controlInterpretation['version_key'] ?? null) ? $controlInterpretation['version_key'] : null;
|
||||||
$controlDisclosure = is_string($controlInterpretation['non_certification_disclosure'] ?? null)
|
$controlDisclosure = is_string($controlInterpretation['non_certification_disclosure'] ?? null)
|
||||||
@ -205,24 +211,47 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($packageGovernanceDecisions !== [])
|
@if ($packageDecisionSummary !== [] || $packageGovernanceDecisions !== [])
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.governance_decisions') }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.governance_decisions_requiring_awareness') }}</div>
|
||||||
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
|
||||||
@foreach ($packageGovernanceDecisions as $decision)
|
|
||||||
@php
|
|
||||||
$decisionTitle = is_string($decision['title'] ?? null) ? $decision['title'] : __('localization.review.governance_decisions');
|
|
||||||
$decisionSummary = is_string($decision['summary'] ?? null) ? $decision['summary'] : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">
|
@if ($packageDecisionSummaryText !== null && $packageDecisionSummaryText !== '')
|
||||||
<div class="font-medium">{{ $decisionTitle }}</div>
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
|
||||||
@if ($decisionSummary !== null && trim($decisionSummary) !== '')
|
{{ $packageDecisionSummaryText }}
|
||||||
<div class="mt-1 text-xs">{{ $decisionSummary }}</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</li>
|
|
||||||
@endforeach
|
@if ($packageDecisionNextAction !== null && $packageDecisionNextAction !== '')
|
||||||
</ul>
|
<div class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
|
||||||
|
{{ $packageDecisionNextAction }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($packageGovernanceDecisions !== [])
|
||||||
|
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
@foreach ($packageGovernanceDecisions as $decision)
|
||||||
|
@php
|
||||||
|
$decisionTitle = is_string($decision['title'] ?? null) ? $decision['title'] : __('localization.review.governance_decisions');
|
||||||
|
$decisionSummary = is_string($decision['summary'] ?? null) ? $decision['summary'] : null;
|
||||||
|
$decisionNextAction = is_string($decision['next_action'] ?? null) ? $decision['next_action'] : null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">
|
||||||
|
<div class="font-medium">{{ $decisionTitle }}</div>
|
||||||
|
@if ($decisionSummary !== null && trim($decisionSummary) !== '')
|
||||||
|
<div class="mt-1 text-xs">{{ $decisionSummary }}</div>
|
||||||
|
@endif
|
||||||
|
@if ($decisionNextAction !== null && trim($decisionNextAction) !== '')
|
||||||
|
<div class="mt-2 text-xs">{{ $decisionNextAction }}</div>
|
||||||
|
@endif
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@elseif ($packageDecisionEmptyState !== null && $packageDecisionEmptyState !== '')
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
|
||||||
|
{{ $packageDecisionEmptyState }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,13 @@
|
|||||||
use App\Filament\Resources\EnvironmentReviewResource;
|
use App\Filament\Resources\EnvironmentReviewResource;
|
||||||
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentReviewPackCard;
|
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentReviewPackCard;
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
use App\Jobs\GenerateReviewPackJob;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Support\EnvironmentReviewStatus;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -13,6 +19,40 @@
|
|||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function spec308SeedExpiredDecisionFinding(ManagedEnvironment $tenant, User $requester, string $title): Finding
|
||||||
|
{
|
||||||
|
$approver = User::factory()->create(['name' => 'Spec 308 Approver']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
/** @var FindingExceptionService $exceptionService */
|
||||||
|
$exceptionService = app(FindingExceptionService::class);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->riskAccepted()->create([
|
||||||
|
'fingerprint' => 'spec-308-raw-fingerprint-'.$title,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'display_name' => $title,
|
||||||
|
'internal_url' => 'https://tenantpilot.test/admin/operations/raw-run',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$requested = $exceptionService->request($finding, $tenant, $requester, [
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'request_reason' => 'Temporary exception for staged remediation.',
|
||||||
|
'review_due_at' => now()->addDays(5)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exceptionService->approve($requested, $approver, [
|
||||||
|
'effective_from' => now()->subDays(10)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->subDay()->toDateTimeString(),
|
||||||
|
'approval_reason' => 'Approved with customer controls.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(FindingRiskGovernanceResolver::class)->syncExceptionState($finding->findingException()->firstOrFail());
|
||||||
|
|
||||||
|
return $finding->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
it('renders an executive-ready environment review and exports a pack with matching section order and summary truth', function (): void {
|
it('renders an executive-ready environment review and exports a pack with matching section order and summary truth', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$review = composeEnvironmentReviewForTest($tenant, $user);
|
$review = composeEnvironmentReviewForTest($tenant, $user);
|
||||||
@ -88,3 +128,93 @@
|
|||||||
->test(ManagedEnvironmentReviewPackCard::class, ['record' => $tenant])
|
->test(ManagedEnvironmentReviewPackCard::class, ['record' => $tenant])
|
||||||
->assertSee('View review');
|
->assertSee('View review');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds an explicit customer-safe decision summary for released review consumption', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(
|
||||||
|
tenant: ManagedEnvironment::factory()->create(['name' => 'Visible Customer Environment']),
|
||||||
|
role: 'owner',
|
||||||
|
);
|
||||||
|
spec308SeedExpiredDecisionFinding($tenant, $user, 'Privileged role exception');
|
||||||
|
|
||||||
|
$hiddenTenant = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Hidden Customer Environment',
|
||||||
|
]);
|
||||||
|
[$hiddenUser, $hiddenTenant] = createUserWithTenant(tenant: $hiddenTenant, role: 'owner');
|
||||||
|
spec308SeedExpiredDecisionFinding($hiddenTenant, $hiddenUser, 'Hidden tenant exception');
|
||||||
|
|
||||||
|
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
||||||
|
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
|
||||||
|
$review->forceFill([
|
||||||
|
'status' => EnvironmentReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$decisionSummary = data_get($review->fresh(), 'summary.governance_package.decision_summary');
|
||||||
|
|
||||||
|
expect($decisionSummary)->toBeArray()
|
||||||
|
->and($decisionSummary['status'] ?? null)->toBe('requires_awareness')
|
||||||
|
->and($decisionSummary['total_count'] ?? null)->toBe(1)
|
||||||
|
->and($decisionSummary['summary'] ?? null)->toContain('1 governance decision requires customer awareness')
|
||||||
|
->and($decisionSummary['next_action'] ?? null)->toBe('Review the accepted-risk decision basis before customer delivery.')
|
||||||
|
->and(data_get($decisionSummary, 'entries.0.title'))->toBe('Privileged role exception')
|
||||||
|
->and(data_get($decisionSummary, 'entries.0.awareness_reason'))->toContain('expired')
|
||||||
|
->and(data_get($decisionSummary, 'entries.0.next_action'))->toBe('Confirm whether this accepted risk should be renewed, remediated, or removed before relying on the review.')
|
||||||
|
->and(json_encode($decisionSummary, JSON_THROW_ON_ERROR))->not->toContain(
|
||||||
|
'Hidden tenant exception',
|
||||||
|
'spec-308-raw-fingerprint',
|
||||||
|
'raw-run',
|
||||||
|
'OperationRun',
|
||||||
|
'Reason owner',
|
||||||
|
'Platform reason family',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(EnvironmentReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?customer_workspace=1&source_surface=customer_review_workspace')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Governance decisions requiring awareness')
|
||||||
|
->assertSee('Privileged role exception')
|
||||||
|
->assertSee('Review the accepted-risk decision basis before customer delivery.')
|
||||||
|
->assertDontSee('Hidden tenant exception')
|
||||||
|
->assertDontSee('raw-run')
|
||||||
|
->assertDontSeeText('Approve exception')
|
||||||
|
->assertDontSeeText('Reject exception')
|
||||||
|
->assertDontSeeText('Renew exception')
|
||||||
|
->assertDontSeeText('Revoke exception');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes no decision awareness from incomplete decision evidence', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$reviewWithNoDecisions = composeEnvironmentReviewForTest(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: seedEnvironmentReviewEvidence($tenant, findingCount: 2, driftCount: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
$noDecisionSummary = data_get($reviewWithNoDecisions, 'summary.governance_package.decision_summary');
|
||||||
|
|
||||||
|
expect($noDecisionSummary)->toBeArray()
|
||||||
|
->and($noDecisionSummary['status'] ?? null)->toBe('none')
|
||||||
|
->and($noDecisionSummary['total_count'] ?? null)->toBe(0)
|
||||||
|
->and($noDecisionSummary['summary'] ?? null)->toBe('No governance decisions require customer awareness in this released review.');
|
||||||
|
|
||||||
|
$partialTenant = ManagedEnvironment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Partial Evidence Environment',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
|
||||||
|
$partialReview = composeEnvironmentReviewForTest(
|
||||||
|
tenant: $partialTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: seedPartialEnvironmentReviewEvidence($partialTenant, findingCount: 0, driftCount: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
$unavailableSummary = data_get($partialReview, 'summary.governance_package.decision_summary');
|
||||||
|
|
||||||
|
expect($unavailableSummary)->toBeArray()
|
||||||
|
->and($unavailableSummary['status'] ?? null)->toBe('unavailable')
|
||||||
|
->and($unavailableSummary['total_count'] ?? null)->toBe(0)
|
||||||
|
->and($unavailableSummary['summary'] ?? null)->toContain('Decision evidence is incomplete');
|
||||||
|
});
|
||||||
|
|||||||
@ -4,7 +4,13 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
use App\Jobs\GenerateReviewPackJob;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\ManagedEnvironment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
|
use App\Support\EnvironmentReviewStatus;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@ -12,6 +18,40 @@
|
|||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function spec308SeedPackDecisionFinding(ManagedEnvironment $tenant, User $requester, string $title): Finding
|
||||||
|
{
|
||||||
|
$approver = User::factory()->create(['name' => 'Risk Owner']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
/** @var FindingExceptionService $exceptionService */
|
||||||
|
$exceptionService = app(FindingExceptionService::class);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->riskAccepted()->create([
|
||||||
|
'fingerprint' => 'spec-308-pack-fingerprint',
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'display_name' => $title,
|
||||||
|
'internal_url' => 'https://tenantpilot.test/admin/operations/raw-review-pack-run',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$requested = $exceptionService->request($finding, $tenant, $requester, [
|
||||||
|
'owner_user_id' => (int) $approver->getKey(),
|
||||||
|
'request_reason' => 'Customer owner approved temporary exception.',
|
||||||
|
'review_due_at' => now()->addDays(5)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exceptionService->approve($requested, $approver, [
|
||||||
|
'effective_from' => now()->subDays(10)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->subDay()->toDateTimeString(),
|
||||||
|
'approval_reason' => 'Approved with customer controls.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(FindingRiskGovernanceResolver::class)->syncExceptionState($finding->findingException()->firstOrFail());
|
||||||
|
|
||||||
|
return $finding->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
it('generates a review-derived executive pack with environment-review metadata and filtered sections', function (): void {
|
it('generates a review-derived executive pack with environment-review metadata and filtered sections', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$review = composeEnvironmentReviewForTest($tenant, $user);
|
$review = composeEnvironmentReviewForTest($tenant, $user);
|
||||||
@ -75,3 +115,69 @@
|
|||||||
->assertSee('#'.$review->getKey())
|
->assertSee('#'.$review->getKey())
|
||||||
->assertSee('Review status');
|
->assertSee('Review status');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes the customer-safe decision summary in review-derived pack JSON and markdown', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(
|
||||||
|
tenant: ManagedEnvironment::factory()->create(['name' => 'Contoso Decision Tenant']),
|
||||||
|
role: 'owner',
|
||||||
|
);
|
||||||
|
spec308SeedPackDecisionFinding($tenant, $user, 'Privileged access accepted risk');
|
||||||
|
|
||||||
|
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
||||||
|
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
|
||||||
|
$review->forceFill([
|
||||||
|
'status' => EnvironmentReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
|
||||||
|
'include_pii' => false,
|
||||||
|
'include_operations' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new GenerateReviewPackJob(
|
||||||
|
reviewPackId: (int) $pack->getKey(),
|
||||||
|
operationRunId: (int) $pack->operation_run_id,
|
||||||
|
);
|
||||||
|
app()->call([$job, 'handle']);
|
||||||
|
|
||||||
|
$pack->refresh();
|
||||||
|
|
||||||
|
expect(data_get($pack->summary, 'governance_package.decision_summary.status'))->toBe('requires_awareness')
|
||||||
|
->and(data_get($pack->summary, 'governance_package.decision_summary.total_count'))->toBe(1)
|
||||||
|
->and(data_get($pack->summary, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT);
|
||||||
|
|
||||||
|
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
|
||||||
|
$tempFile = tempnam(sys_get_temp_dir(), 'review-derived-pack-decisions-');
|
||||||
|
file_put_contents($tempFile, $zipContent);
|
||||||
|
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$zip->open($tempFile);
|
||||||
|
|
||||||
|
$filenames = collect(range(0, $zip->numFiles - 1))
|
||||||
|
->map(fn (int $index): string => (string) $zip->getNameIndex($index))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$executiveEntrypoint = (string) $zip->getFromName(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME);
|
||||||
|
$summaryJson = json_encode($summary, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($filenames)->toContain('metadata.json', 'summary.json', 'sections.json', ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||||
|
->and(collect($filenames)->filter(fn (string $filename): bool => str_starts_with($filename, 'executive-'))->values()->all())->toBe([ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME])
|
||||||
|
->and(data_get($metadata, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT)
|
||||||
|
->and(data_get($summary, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT)
|
||||||
|
->and(data_get($summary, 'governance_package.decision_summary.status'))->toBe('requires_awareness')
|
||||||
|
->and(data_get($summary, 'governance_package.decision_summary.total_count'))->toBe(1)
|
||||||
|
->and(data_get($summary, 'governance_package.decision_summary.entries.0.title'))->toBe('Privileged access accepted risk')
|
||||||
|
->and($executiveEntrypoint)->toContain('## Governance decisions requiring awareness')
|
||||||
|
->and($executiveEntrypoint)->toContain('1 governance decision requires customer awareness')
|
||||||
|
->and($executiveEntrypoint)->toContain('Privileged access accepted risk')
|
||||||
|
->and($executiveEntrypoint)->toContain('Review the accepted-risk decision basis before customer delivery.')
|
||||||
|
->and($summaryJson)->not->toContain('Contoso Decision Tenant', 'Risk Owner', 'spec-308-pack-fingerprint', 'raw-review-pack-run')
|
||||||
|
->and($executiveEntrypoint)->not->toContain('Contoso Decision Tenant', 'Risk Owner', 'spec-308-pack-fingerprint', 'raw-review-pack-run', 'OperationRun URL');
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
unlink($tempFile);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user