516 lines
21 KiB
PHP
516 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Governance\Controls;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Models\Finding;
|
|
use App\Support\TenantReviewCompletenessState;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
final readonly class ComplianceEvidenceMappingV1
|
|
{
|
|
public const string VERSION_KEY = 'compliance_evidence_mapping.v1';
|
|
|
|
public const string SECTION_KEY = 'control_interpretation';
|
|
|
|
public function __construct(
|
|
private CanonicalControlCatalog $catalog,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* summary: array<string, mixed>,
|
|
* section: array<string, mixed>
|
|
* }
|
|
*/
|
|
public function interpret(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem): array
|
|
{
|
|
$findingsSummary = $this->findingsSummary($findingsItem);
|
|
$entries = $this->findingEntries($findingsSummary);
|
|
$unresolvedEntryCount = $entries
|
|
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.status') !== 'resolved')
|
|
->count();
|
|
$controls = $this->controlDefinitions($findingsSummary, $entries);
|
|
$snapshotLimitations = $this->snapshotLimitations($snapshot, $findingsItem, $unresolvedEntryCount);
|
|
|
|
$controlSummaries = $controls
|
|
->map(fn (CanonicalControlDefinition $definition): array => $this->controlSummary(
|
|
definition: $definition,
|
|
entries: $this->entriesForControl($entries, $definition->controlKey),
|
|
snapshotLimitations: $snapshotLimitations,
|
|
))
|
|
->values()
|
|
->all();
|
|
|
|
$globalLimitations = $this->globalLimitations($controlSummaries, $snapshotLimitations, $controls->isEmpty(), $unresolvedEntryCount);
|
|
$limitationCounts = $this->limitationCounts($controlSummaries, $globalLimitations);
|
|
|
|
$summary = [
|
|
'version_key' => self::VERSION_KEY,
|
|
'display_label' => 'Compliance evidence mapping v1',
|
|
'non_certification_disclosure' => 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
|
'mapped_control_count' => count($controlSummaries),
|
|
'follow_up_required_count' => collect($controlSummaries)
|
|
->where('readiness_bucket', 'follow_up_required')
|
|
->count(),
|
|
'limitation_counts' => $limitationCounts,
|
|
'limitations' => $globalLimitations,
|
|
'controls' => $controlSummaries,
|
|
];
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'section' => [
|
|
'section_key' => self::SECTION_KEY,
|
|
'title' => 'Control readiness interpretation',
|
|
'sort_order' => 15,
|
|
'required' => true,
|
|
'completeness_state' => $this->sectionCompleteness($findingsItem, $controls->isEmpty(), $snapshotLimitations),
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem) ?? (string) $snapshot->fingerprint,
|
|
'summary_payload' => Arr::except($summary, ['controls']),
|
|
'render_payload' => [
|
|
'entries' => array_map(
|
|
fn (array $control): array => $this->controlExplanation($control, $snapshot),
|
|
$controlSummaries,
|
|
),
|
|
'disclosure' => $summary['non_certification_disclosure'],
|
|
'next_actions' => $this->sectionNextActions($controlSummaries, $globalLimitations),
|
|
'empty_state' => $controlSummaries === []
|
|
? 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.'
|
|
: null,
|
|
],
|
|
'measured_at' => $findingsItem?->measured_at ?? $snapshot->generated_at,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function findingsSummary(?EvidenceSnapshotItem $findingsItem): array
|
|
{
|
|
return is_array($findingsItem?->summary_payload) ? $findingsItem->summary_payload : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $findingsSummary
|
|
* @return Collection<int, array<string, mixed>>
|
|
*/
|
|
private function findingEntries(array $findingsSummary): Collection
|
|
{
|
|
return collect(Arr::wrap($findingsSummary['entries'] ?? []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry))
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $findingsSummary
|
|
* @param Collection<int, array<string, mixed>> $entries
|
|
* @return Collection<int, CanonicalControlDefinition>
|
|
*/
|
|
private function controlDefinitions(array $findingsSummary, Collection $entries): Collection
|
|
{
|
|
$summaryControls = collect(Arr::wrap($findingsSummary['canonical_controls'] ?? []))
|
|
->filter(static fn (mixed $control): bool => is_array($control));
|
|
|
|
$entryControls = $entries
|
|
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
|
|
->filter(static fn (mixed $control): bool => is_array($control));
|
|
|
|
return $summaryControls
|
|
->merge($entryControls)
|
|
->map(fn (array $control): ?CanonicalControlDefinition => $this->definitionFor($control))
|
|
->filter()
|
|
->unique(static fn (CanonicalControlDefinition $definition): string => $definition->controlKey)
|
|
->sortBy(static fn (CanonicalControlDefinition $definition): string => $definition->name)
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $control
|
|
*/
|
|
private function definitionFor(array $control): ?CanonicalControlDefinition
|
|
{
|
|
$controlKey = $control['control_key'] ?? null;
|
|
|
|
if (! is_string($controlKey) || trim($controlKey) === '') {
|
|
return null;
|
|
}
|
|
|
|
return $this->catalog->find($controlKey);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, array<string, mixed>> $entries
|
|
* @return Collection<int, array<string, mixed>>
|
|
*/
|
|
private function entriesForControl(Collection $entries, string $controlKey): Collection
|
|
{
|
|
return $entries
|
|
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.control.control_key') === $controlKey)
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, array<string, mixed>> $entries
|
|
* @param list<string> $snapshotLimitations
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function controlSummary(CanonicalControlDefinition $definition, Collection $entries, array $snapshotLimitations): array
|
|
{
|
|
$openEntries = $entries->filter(static fn (array $entry): bool => in_array((string) ($entry['status'] ?? ''), Finding::openStatuses(), true));
|
|
$acceptedRiskEntries = $entries->filter(static fn (array $entry): bool => (string) ($entry['status'] ?? '') === Finding::STATUS_RISK_ACCEPTED);
|
|
$governanceWarnings = $entries->filter(static fn (array $entry): bool => self::hasGovernanceWarning($entry));
|
|
$limitationFlags = $this->controlLimitations($acceptedRiskEntries->count(), $snapshotLimitations);
|
|
$readinessBucket = $this->readinessBucket(
|
|
openCount: $openEntries->count(),
|
|
acceptedRiskCount: $acceptedRiskEntries->count(),
|
|
governanceWarningCount: $governanceWarnings->count(),
|
|
limitationFlags: $limitationFlags,
|
|
);
|
|
|
|
return [
|
|
'control_key' => $definition->controlKey,
|
|
'control_name' => $definition->name,
|
|
'domain_key' => $definition->domainKey,
|
|
'readiness_bucket' => $readinessBucket,
|
|
'readiness_label' => self::readinessLabel($readinessBucket),
|
|
'limitation_flags' => $limitationFlags,
|
|
'limitation_labels' => array_map(self::limitationLabel(...), $limitationFlags),
|
|
'customer_summary' => $this->customerSummary($definition, $readinessBucket, $openEntries->count(), $acceptedRiskEntries->count()),
|
|
'evidence_basis_summary' => $this->evidenceBasisSummary($entries->count(), $openEntries->count(), $acceptedRiskEntries->count()),
|
|
'accepted_risk_summary' => $acceptedRiskEntries->isEmpty()
|
|
? null
|
|
: $this->acceptedRiskSummary($acceptedRiskEntries, $governanceWarnings->count()),
|
|
'recommended_next_action' => $this->recommendedNextAction($readinessBucket, $acceptedRiskEntries->count(), $limitationFlags),
|
|
'detail_anchor' => 'control-'.$definition->controlKey,
|
|
'supporting_finding_ids' => $entries
|
|
->pluck('id')
|
|
->filter(static fn (mixed $id): bool => is_numeric($id))
|
|
->map(static fn (mixed $id): int => (int) $id)
|
|
->values()
|
|
->all(),
|
|
'finding_count' => $entries->count(),
|
|
'open_finding_count' => $openEntries->count(),
|
|
'accepted_risk_count' => $acceptedRiskEntries->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, array<string, mixed>> $acceptedRiskEntries
|
|
*/
|
|
private function acceptedRiskSummary(Collection $acceptedRiskEntries, int $governanceWarningCount): string
|
|
{
|
|
if ($governanceWarningCount > 0) {
|
|
return sprintf(
|
|
'%d accepted-risk finding(s) need governance follow-up before relying on this interpretation.',
|
|
$acceptedRiskEntries->count(),
|
|
);
|
|
}
|
|
|
|
return sprintf(
|
|
'%d accepted-risk finding(s) are part of the evidence basis and qualify the readiness view.',
|
|
$acceptedRiskEntries->count(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $snapshotLimitations
|
|
* @return list<string>
|
|
*/
|
|
private function controlLimitations(int $acceptedRiskCount, array $snapshotLimitations): array
|
|
{
|
|
$limitations = $snapshotLimitations;
|
|
|
|
if ($acceptedRiskCount > 0) {
|
|
$limitations[] = 'accepted_risk_influenced';
|
|
}
|
|
|
|
return array_values(array_unique($limitations));
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $limitationFlags
|
|
*/
|
|
private function readinessBucket(int $openCount, int $acceptedRiskCount, int $governanceWarningCount, array $limitationFlags): string
|
|
{
|
|
if ($openCount > 0 || $governanceWarningCount > 0) {
|
|
return 'follow_up_required';
|
|
}
|
|
|
|
if ($acceptedRiskCount > 0 || $limitationFlags !== []) {
|
|
return 'review_recommended';
|
|
}
|
|
|
|
return 'evidence_on_record';
|
|
}
|
|
|
|
private function customerSummary(CanonicalControlDefinition $definition, string $readinessBucket, int $openCount, int $acceptedRiskCount): string
|
|
{
|
|
return match ($readinessBucket) {
|
|
'follow_up_required' => sprintf(
|
|
'%s needs follow-up because %d open finding(s) remain in the released evidence basis.',
|
|
$definition->name,
|
|
$openCount,
|
|
),
|
|
'review_recommended' => $acceptedRiskCount > 0
|
|
? sprintf('%s has evidence on record with accepted-risk context that should be reviewed before relying on the interpretation.', $definition->name)
|
|
: sprintf('%s has evidence on record, with limitations that should be reviewed before relying on the interpretation.', $definition->name),
|
|
default => sprintf('%s has evidence on record in this released review.', $definition->name),
|
|
};
|
|
}
|
|
|
|
private function evidenceBasisSummary(int $signalCount, int $openCount, int $acceptedRiskCount): string
|
|
{
|
|
$parts = [
|
|
sprintf('%d evidence signal(s) reference this control.', $signalCount),
|
|
];
|
|
|
|
if ($openCount > 0) {
|
|
$parts[] = sprintf('%d open finding(s) still need follow-up.', $openCount);
|
|
}
|
|
|
|
if ($acceptedRiskCount > 0) {
|
|
$parts[] = sprintf('%d accepted-risk finding(s) qualify this view.', $acceptedRiskCount);
|
|
}
|
|
|
|
return implode(' ', $parts);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $limitationFlags
|
|
*/
|
|
private function recommendedNextAction(string $readinessBucket, int $acceptedRiskCount, array $limitationFlags): string
|
|
{
|
|
if ($readinessBucket === 'follow_up_required') {
|
|
return 'Review the surfaced findings with the tenant and agree ownership plus follow-up timing.';
|
|
}
|
|
|
|
if ($acceptedRiskCount > 0) {
|
|
return 'Review the accepted-risk owner and next review date before customer delivery.';
|
|
}
|
|
|
|
if ($limitationFlags !== []) {
|
|
return 'Confirm the evidence basis and limitations before using this control as customer-facing readiness support.';
|
|
}
|
|
|
|
return 'Keep this evidence on record and revisit it during the normal review cadence.';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $control
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function controlExplanation(array $control, EvidenceSnapshot $snapshot): array
|
|
{
|
|
return [
|
|
'title' => $control['control_name'],
|
|
'control_key' => $control['control_key'],
|
|
'control_name' => $control['control_name'],
|
|
'readiness_bucket' => $control['readiness_bucket'],
|
|
'readiness_label' => $control['readiness_label'],
|
|
'limitation_flags' => $control['limitation_flags'],
|
|
'limitation_labels' => $control['limitation_labels'],
|
|
'customer_summary' => $control['customer_summary'],
|
|
'evidence_basis_summary' => $control['evidence_basis_summary'],
|
|
'accepted_risk_summary' => $control['accepted_risk_summary'],
|
|
'explanation_text' => $control['customer_summary'],
|
|
'evidence_basis_items' => array_values(array_filter([
|
|
$control['evidence_basis_summary'],
|
|
$control['accepted_risk_summary'],
|
|
])),
|
|
'accepted_risk_context' => $control['accepted_risk_summary'],
|
|
'recommended_next_action' => $control['recommended_next_action'],
|
|
'proof_access_state' => $this->proofAccessState($snapshot),
|
|
'supporting_finding_ids' => $control['supporting_finding_ids'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $controlSummaries
|
|
* @param list<string> $globalLimitations
|
|
* @return list<string>
|
|
*/
|
|
private function sectionNextActions(array $controlSummaries, array $globalLimitations): array
|
|
{
|
|
if ($controlSummaries === []) {
|
|
return ['Review unmapped evidence before using this review for customer-facing readiness discussions.'];
|
|
}
|
|
|
|
$actions = collect($controlSummaries)
|
|
->pluck('recommended_next_action')
|
|
->filter(static fn (mixed $action): bool => is_string($action) && trim($action) !== '')
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
if (in_array('unmapped', $globalLimitations, true)) {
|
|
$actions[] = 'Treat this review as partial until unmapped evidence can be interpreted.';
|
|
}
|
|
|
|
return array_values(array_unique($actions));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $controlSummaries
|
|
* @param list<string> $snapshotLimitations
|
|
* @return list<string>
|
|
*/
|
|
private function globalLimitations(array $controlSummaries, array $snapshotLimitations, bool $noMappedControls, int $unresolvedEntryCount): array
|
|
{
|
|
$limitations = $snapshotLimitations;
|
|
|
|
if ($noMappedControls) {
|
|
$limitations[] = 'unmapped';
|
|
}
|
|
|
|
if ($unresolvedEntryCount > 0) {
|
|
$limitations[] = 'partial_mapping';
|
|
}
|
|
|
|
foreach ($controlSummaries as $control) {
|
|
foreach (Arr::wrap($control['limitation_flags'] ?? []) as $limitation) {
|
|
if (is_string($limitation) && trim($limitation) !== '') {
|
|
$limitations[] = $limitation;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($limitations));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $controlSummaries
|
|
* @param list<string> $globalLimitations
|
|
* @return array<string, int>
|
|
*/
|
|
private function limitationCounts(array $controlSummaries, array $globalLimitations): array
|
|
{
|
|
$counts = collect($controlSummaries)
|
|
->flatMap(static fn (array $control): array => Arr::wrap($control['limitation_flags'] ?? []))
|
|
->filter(static fn (mixed $limitation): bool => is_string($limitation) && trim($limitation) !== '')
|
|
->countBy()
|
|
->all();
|
|
|
|
foreach ($globalLimitations as $limitation) {
|
|
$counts[$limitation] = max((int) ($counts[$limitation] ?? 0), 1);
|
|
}
|
|
|
|
ksort($counts);
|
|
|
|
return array_map('intval', $counts);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function snapshotLimitations(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem, int $unresolvedEntryCount): array
|
|
{
|
|
$limitations = [];
|
|
$state = (string) ($findingsItem?->state ?? $snapshot->completeness_state);
|
|
|
|
if ($state === TenantReviewCompletenessState::Stale->value || (string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
|
$limitations[] = 'stale_evidence';
|
|
}
|
|
|
|
if (in_array($state, [TenantReviewCompletenessState::Partial->value, TenantReviewCompletenessState::Missing->value], true)) {
|
|
$limitations[] = 'partial_mapping';
|
|
}
|
|
|
|
if ($unresolvedEntryCount > 0) {
|
|
$limitations[] = 'partial_mapping';
|
|
}
|
|
|
|
if (! $snapshot->exists || $snapshot->generated_at === null) {
|
|
$limitations[] = 'supporting_evidence_unavailable';
|
|
}
|
|
|
|
return array_values(array_unique($limitations));
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $snapshotLimitations
|
|
*/
|
|
private function sectionCompleteness(?EvidenceSnapshotItem $findingsItem, bool $noMappedControls, array $snapshotLimitations): string
|
|
{
|
|
if (! $findingsItem instanceof EvidenceSnapshotItem) {
|
|
return TenantReviewCompletenessState::Missing->value;
|
|
}
|
|
|
|
if (in_array('stale_evidence', $snapshotLimitations, true)) {
|
|
return TenantReviewCompletenessState::Stale->value;
|
|
}
|
|
|
|
if ($noMappedControls || in_array('partial_mapping', $snapshotLimitations, true)) {
|
|
return TenantReviewCompletenessState::Partial->value;
|
|
}
|
|
|
|
return TenantReviewCompletenessState::tryFrom((string) $findingsItem->state)?->value
|
|
?? TenantReviewCompletenessState::Missing->value;
|
|
}
|
|
|
|
private function proofAccessState(EvidenceSnapshot $snapshot): string
|
|
{
|
|
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
|
return 'expired';
|
|
}
|
|
|
|
if (! $snapshot->exists || $snapshot->generated_at === null) {
|
|
return 'unavailable';
|
|
}
|
|
|
|
return 'available';
|
|
}
|
|
|
|
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
|
{
|
|
$fingerprint = $item?->source_fingerprint;
|
|
|
|
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private static function hasGovernanceWarning(array $entry): bool
|
|
{
|
|
if (is_string($entry['governance_warning'] ?? null) && trim((string) $entry['governance_warning']) !== '') {
|
|
return true;
|
|
}
|
|
|
|
return in_array((string) ($entry['governance_state'] ?? ''), [
|
|
'expired_exception',
|
|
'revoked_exception',
|
|
'rejected_exception',
|
|
'risk_accepted_without_valid_exception',
|
|
], true);
|
|
}
|
|
|
|
public static function readinessLabel(string $bucket): string
|
|
{
|
|
return match ($bucket) {
|
|
'follow_up_required' => 'Follow-up required',
|
|
'review_recommended' => 'Review recommended',
|
|
'evidence_on_record' => 'Evidence on record',
|
|
default => Str::headline($bucket),
|
|
};
|
|
}
|
|
|
|
public static function limitationLabel(string $flag): string
|
|
{
|
|
return match ($flag) {
|
|
'accepted_risk_influenced' => 'Accepted risk influences this view',
|
|
'partial_mapping' => 'Partial evidence mapping',
|
|
'stale_evidence' => 'Evidence freshness needs review',
|
|
'supporting_evidence_unavailable' => 'Supporting evidence unavailable',
|
|
'unmapped' => 'No mapped control coverage',
|
|
default => Str::headline($flag),
|
|
};
|
|
}
|
|
}
|