## Summary - add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export - extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles - add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates ## Testing - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact --filter="TenantReview"` - `CI=1 vendor/bin/sail artisan test --compact` ## Notes - Livewire v4+ compliant via existing Filament v5 stack - panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php` - `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply - destructive review actions use action handlers with confirmation and policy enforcement Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #185
344 lines
15 KiB
PHP
344 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantReviews;
|
|
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Support\TenantReviewCompletenessState;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class TenantReviewSectionFactory
|
|
{
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function make(EvidenceSnapshot $snapshot): array
|
|
{
|
|
$items = $snapshot->items->keyBy('dimension_key');
|
|
$findingsItem = $this->item($items, 'findings_summary');
|
|
$permissionItem = $this->item($items, 'permission_posture');
|
|
$rolesItem = $this->item($items, 'entra_admin_roles');
|
|
$baselineItem = $this->item($items, 'baseline_drift_posture');
|
|
$operationsItem = $this->item($items, 'operations_summary');
|
|
|
|
return [
|
|
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
|
|
$this->openRisksSection($findingsItem),
|
|
$this->acceptedRisksSection($findingsItem),
|
|
$this->permissionPostureSection($permissionItem, $rolesItem),
|
|
$this->baselineDriftSection($baselineItem),
|
|
$this->operationsHealthSection($operationsItem),
|
|
];
|
|
}
|
|
|
|
private function executiveSummarySection(
|
|
EvidenceSnapshot $snapshot,
|
|
?EvidenceSnapshotItem $findingsItem,
|
|
?EvidenceSnapshotItem $permissionItem,
|
|
?EvidenceSnapshotItem $rolesItem,
|
|
?EvidenceSnapshotItem $baselineItem,
|
|
?EvidenceSnapshotItem $operationsItem,
|
|
): array {
|
|
$findingsSummary = $this->summary($findingsItem);
|
|
$permissionSummary = $this->summary($permissionItem);
|
|
$rolesSummary = $this->summary($rolesItem);
|
|
$baselineSummary = $this->summary($baselineItem);
|
|
$operationsSummary = $this->summary($operationsItem);
|
|
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
|
|
|
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
|
$findingCount = (int) ($findingsSummary['count'] ?? 0);
|
|
$driftCount = (int) ($baselineSummary['open_drift_count'] ?? 0);
|
|
$postureScore = $permissionSummary['posture_score'] ?? null;
|
|
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
|
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
|
|
|
$highlights = array_values(array_filter([
|
|
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
|
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
|
sprintf('%d baseline drift findings remain open.', $driftCount),
|
|
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
|
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
|
|
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
|
|
]));
|
|
|
|
return [
|
|
'section_key' => 'executive_summary',
|
|
'title' => 'Executive summary',
|
|
'sort_order' => 10,
|
|
'required' => true,
|
|
'completeness_state' => $this->maxState([
|
|
$this->state($findingsItem),
|
|
$this->state($permissionItem),
|
|
$this->state($rolesItem),
|
|
$this->state($baselineItem),
|
|
$this->state($operationsItem),
|
|
])->value,
|
|
'source_snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'summary_payload' => [
|
|
'finding_count' => $findingCount,
|
|
'open_risk_count' => $openCount,
|
|
'posture_score' => $postureScore,
|
|
'baseline_drift_count' => $driftCount,
|
|
'failed_operation_count' => $operationFailures,
|
|
'partial_operation_count' => $partialOperations,
|
|
'risk_acceptance' => $riskAcceptance,
|
|
],
|
|
'render_payload' => [
|
|
'highlights' => $highlights,
|
|
'next_actions' => $this->nextActions(
|
|
openCount: $openCount,
|
|
driftCount: $driftCount,
|
|
operationFailures: $operationFailures,
|
|
postureScore: is_numeric($postureScore) ? (int) $postureScore : null,
|
|
riskWarnings: (int) ($riskAcceptance['warning_count'] ?? 0),
|
|
),
|
|
'included_dimensions' => collect($snapshot->items)
|
|
->map(static fn (EvidenceSnapshotItem $item): array => [
|
|
'key' => (string) $item->dimension_key,
|
|
'state' => (string) $item->state,
|
|
'required' => (bool) $item->required,
|
|
])
|
|
->values()
|
|
->all(),
|
|
],
|
|
'measured_at' => $snapshot->generated_at,
|
|
];
|
|
}
|
|
|
|
private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
|
{
|
|
$summary = $this->summary($findingsItem);
|
|
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true))
|
|
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
|
|
'critical' => 4,
|
|
'high' => 3,
|
|
'medium' => 2,
|
|
default => 1,
|
|
})
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'section_key' => 'open_risks',
|
|
'title' => 'Open risk highlights',
|
|
'sort_order' => 20,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($findingsItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem),
|
|
'summary_payload' => [
|
|
'open_count' => (int) ($summary['open_count'] ?? 0),
|
|
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
|
|
],
|
|
'render_payload' => [
|
|
'entries' => $entries,
|
|
'empty_state' => empty($entries) ? 'No open risks are recorded in the anchored evidence basis.' : null,
|
|
],
|
|
'measured_at' => $findingsItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
|
{
|
|
$summary = $this->summary($findingsItem);
|
|
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
|
->filter(static fn (mixed $entry): bool => is_array($entry) && (string) ($entry['status'] ?? '') === 'risk_accepted')
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
|
|
|
return [
|
|
'section_key' => 'accepted_risks',
|
|
'title' => 'Accepted risk summary',
|
|
'sort_order' => 30,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($findingsItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem),
|
|
'summary_payload' => [
|
|
'status_marked_count' => (int) ($riskAcceptance['status_marked_count'] ?? 0),
|
|
'valid_governed_count' => (int) ($riskAcceptance['valid_governed_count'] ?? 0),
|
|
'warning_count' => (int) ($riskAcceptance['warning_count'] ?? 0),
|
|
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
|
|
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
|
|
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
|
|
],
|
|
'render_payload' => [
|
|
'entries' => $entries,
|
|
'disclosure' => (int) ($riskAcceptance['warning_count'] ?? 0) > 0
|
|
? 'Some accepted risks need governance follow-up before stakeholder delivery.'
|
|
: 'Accepted risks are governed by the anchored evidence basis.',
|
|
],
|
|
'measured_at' => $findingsItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function permissionPostureSection(?EvidenceSnapshotItem $permissionItem, ?EvidenceSnapshotItem $rolesItem): array
|
|
{
|
|
$permissionSummary = $this->summary($permissionItem);
|
|
$rolesSummary = $this->summary($rolesItem);
|
|
|
|
return [
|
|
'section_key' => 'permission_posture',
|
|
'title' => 'Permission posture',
|
|
'sort_order' => 40,
|
|
'required' => true,
|
|
'completeness_state' => $this->maxState([
|
|
$this->state($permissionItem),
|
|
$this->state($rolesItem),
|
|
])->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($permissionItem) ?? $this->sourceFingerprint($rolesItem),
|
|
'summary_payload' => [
|
|
'posture_score' => $permissionSummary['posture_score'] ?? null,
|
|
'required_count' => (int) ($permissionSummary['required_count'] ?? 0),
|
|
'granted_count' => (int) ($permissionSummary['granted_count'] ?? 0),
|
|
'role_count' => (int) ($rolesSummary['role_count'] ?? 0),
|
|
],
|
|
'render_payload' => [
|
|
'permission_payload' => is_array($permissionSummary['payload'] ?? null) ? $permissionSummary['payload'] : [],
|
|
'roles' => is_array($rolesSummary['roles'] ?? null) ? $rolesSummary['roles'] : [],
|
|
],
|
|
'measured_at' => $permissionItem?->measured_at ?? $rolesItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function baselineDriftSection(?EvidenceSnapshotItem $baselineItem): array
|
|
{
|
|
$summary = $this->summary($baselineItem);
|
|
|
|
return [
|
|
'section_key' => 'baseline_drift_posture',
|
|
'title' => 'Baseline drift posture',
|
|
'sort_order' => 50,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($baselineItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($baselineItem),
|
|
'summary_payload' => [
|
|
'drift_count' => (int) ($summary['drift_count'] ?? 0),
|
|
'open_drift_count' => (int) ($summary['open_drift_count'] ?? 0),
|
|
],
|
|
'render_payload' => [
|
|
'disclosure' => (int) ($summary['open_drift_count'] ?? 0) > 0
|
|
? 'Baseline drift remains visible in this review and should be discussed as hardening work.'
|
|
: 'No open baseline drift findings are present in the anchored evidence basis.',
|
|
],
|
|
'measured_at' => $baselineItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function operationsHealthSection(?EvidenceSnapshotItem $operationsItem): array
|
|
{
|
|
$summary = $this->summary($operationsItem);
|
|
|
|
return [
|
|
'section_key' => 'operations_health',
|
|
'title' => 'Operations health',
|
|
'sort_order' => 60,
|
|
'required' => true,
|
|
'completeness_state' => $this->state($operationsItem)->value,
|
|
'source_snapshot_fingerprint' => $this->sourceFingerprint($operationsItem),
|
|
'summary_payload' => [
|
|
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
'failed_count' => (int) ($summary['failed_count'] ?? 0),
|
|
'partial_count' => (int) ($summary['partial_count'] ?? 0),
|
|
],
|
|
'render_payload' => [
|
|
'entries' => array_values(array_slice(Arr::wrap($summary['entries'] ?? []), 0, 10)),
|
|
],
|
|
'measured_at' => $operationsItem?->measured_at,
|
|
];
|
|
}
|
|
|
|
private function item(Collection $items, string $key): ?EvidenceSnapshotItem
|
|
{
|
|
$item = $items->get($key);
|
|
|
|
return $item instanceof EvidenceSnapshotItem ? $item : null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function summary(?EvidenceSnapshotItem $item): array
|
|
{
|
|
return is_array($item?->summary_payload) ? $item->summary_payload : [];
|
|
}
|
|
|
|
private function state(?EvidenceSnapshotItem $item): TenantReviewCompletenessState
|
|
{
|
|
return TenantReviewCompletenessState::tryFrom((string) $item?->state)
|
|
?? TenantReviewCompletenessState::Missing;
|
|
}
|
|
|
|
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
|
{
|
|
$fingerprint = $item?->source_fingerprint;
|
|
|
|
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantReviewCompletenessState> $states
|
|
*/
|
|
private function maxState(array $states): TenantReviewCompletenessState
|
|
{
|
|
if (in_array(TenantReviewCompletenessState::Missing, $states, true)) {
|
|
return TenantReviewCompletenessState::Missing;
|
|
}
|
|
|
|
if (in_array(TenantReviewCompletenessState::Stale, $states, true)) {
|
|
return TenantReviewCompletenessState::Stale;
|
|
}
|
|
|
|
if (in_array(TenantReviewCompletenessState::Partial, $states, true)) {
|
|
return TenantReviewCompletenessState::Partial;
|
|
}
|
|
|
|
return TenantReviewCompletenessState::Complete;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function nextActions(
|
|
int $openCount,
|
|
int $driftCount,
|
|
int $operationFailures,
|
|
?int $postureScore,
|
|
int $riskWarnings,
|
|
): array {
|
|
$actions = [];
|
|
|
|
if ($openCount > 0) {
|
|
$actions[] = 'Review the highest-severity open findings with the tenant and confirm ownership.';
|
|
}
|
|
|
|
if ($riskWarnings > 0) {
|
|
$actions[] = 'Reconcile accepted-risk governance records before external delivery.';
|
|
}
|
|
|
|
if ($postureScore !== null && $postureScore < 80) {
|
|
$actions[] = 'Prioritize missing permissions or posture controls that materially affect review confidence.';
|
|
}
|
|
|
|
if ($driftCount > 0) {
|
|
$actions[] = 'Schedule remediation for recurring baseline drift to reduce repeated review findings.';
|
|
}
|
|
|
|
if ($operationFailures > 0) {
|
|
$actions[] = 'Inspect recent failed operations to confirm tenant management workflows are stable.';
|
|
}
|
|
|
|
if ($actions === []) {
|
|
$actions[] = 'No immediate corrective action is required beyond the normal review cadence.';
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
}
|