- T001-T014: Foundation - StoredReport model/migration, Finding resolved lifecycle, badge mappings (resolved status, permission_posture type), OperationCatalog + AlertRule constants - T015-T022: US1 - PermissionPostureFindingGenerator with fingerprint-based idempotent upsert, severity from feature-impact count, auto-resolve on grant, auto-reopen on revoke, error findings (FR-015), stale finding cleanup; GeneratePermissionPostureFindingsJob dispatched from health check; PostureResult VO + PostureScoreCalculator - T023-T026: US2+US4 - Stored report payload validation, temporal ordering, polymorphic reusability, score accuracy acceptance tests - T027-T029: US3 - EvaluateAlertsJob.permissionMissingEvents() wired into alert pipeline, AlertRuleResource event type option, cooldown/dedupe tests - T030-T034: Polish - PruneStoredReportsCommand with config retention, scheduled daily, end-to-end integration test, Pint clean UI bug fixes found during testing: - FindingResource: hide Diff section for non-drift findings - TenantRequiredPermissions: fix re-run verification link - tenant-required-permissions.blade.php: preserve details open state 70 tests (50 PermissionPosture + 20 FindingResolved/Badge/Alert), 216 assertions
348 lines
12 KiB
PHP
348 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\PermissionPosture;
|
|
|
|
use App\Models\AlertRule;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\StoredReport;
|
|
use App\Models\Tenant;
|
|
|
|
/**
|
|
* Generates, auto-resolves, and re-opens permission posture findings
|
|
* based on the output of TenantPermissionService::compare().
|
|
*/
|
|
final class PermissionPostureFindingGenerator implements FindingGeneratorContract
|
|
{
|
|
public function __construct(
|
|
private readonly PostureScoreCalculator $scoreCalculator,
|
|
) {}
|
|
|
|
/**
|
|
* @param array{overall_status: string, permissions: array<int, array{key: string, type: string, features: array<int, string>, status: string, ...}>, ...} $permissionComparison
|
|
*/
|
|
public function generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult
|
|
{
|
|
$permissions = $permissionComparison['permissions'] ?? [];
|
|
$permissions = is_array($permissions) ? $permissions : [];
|
|
|
|
$created = 0;
|
|
$resolved = 0;
|
|
$reopened = 0;
|
|
$unchanged = 0;
|
|
$errors = 0;
|
|
$alertEvents = [];
|
|
$processedPermissionKeys = [];
|
|
|
|
foreach ($permissions as $permission) {
|
|
if (! is_array($permission)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $permission['key'] ?? '';
|
|
$type = $permission['type'] ?? 'application';
|
|
$status = $permission['status'] ?? 'granted';
|
|
$features = is_array($permission['features'] ?? null) ? $permission['features'] : [];
|
|
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
|
|
$processedPermissionKeys[] = $key;
|
|
|
|
if ($status === 'error') {
|
|
$this->handleErrorPermission($tenant, $key, $type, $features, $operationRun);
|
|
$errors++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($status === 'missing') {
|
|
$result = $this->handleMissingPermission($tenant, $key, $type, $features, $operationRun);
|
|
|
|
if ($result === 'created') {
|
|
$created++;
|
|
$alertEvents[] = $this->buildAlertEvent($tenant, $key, $type, $features);
|
|
} elseif ($result === 'reopened') {
|
|
$reopened++;
|
|
$alertEvents[] = $this->buildAlertEvent($tenant, $key, $type, $features);
|
|
} else {
|
|
$unchanged++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// status === 'granted'
|
|
if ($this->resolveExistingFinding($tenant, $key, 'permission_granted')) {
|
|
$resolved++;
|
|
}
|
|
}
|
|
|
|
// Step 9: Resolve stale findings for permissions removed from registry
|
|
$resolved += $this->resolveStaleFindings($tenant, $processedPermissionKeys);
|
|
|
|
$postureScore = $this->scoreCalculator->calculate($permissionComparison);
|
|
|
|
$report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore);
|
|
|
|
return new PostureResult(
|
|
findingsCreated: $created,
|
|
findingsResolved: $resolved,
|
|
findingsReopened: $reopened,
|
|
findingsUnchanged: $unchanged,
|
|
errorsRecorded: $errors,
|
|
postureScore: $postureScore,
|
|
storedReportId: (int) $report->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getAlertEvents(): array
|
|
{
|
|
return $this->alertEvents;
|
|
}
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
private array $alertEvents = [];
|
|
|
|
private function handleMissingPermission(
|
|
Tenant $tenant,
|
|
string $key,
|
|
string $type,
|
|
array $features,
|
|
?OperationRun $operationRun,
|
|
): string {
|
|
$fingerprint = $this->fingerprint($tenant, $key);
|
|
$evidence = $this->buildEvidence($key, $type, 'missing', $features);
|
|
$severity = $this->deriveSeverity(count($features));
|
|
|
|
$finding = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('fingerprint', $fingerprint)
|
|
->first();
|
|
|
|
if ($finding instanceof Finding) {
|
|
if ($finding->status === Finding::STATUS_RESOLVED) {
|
|
$finding->reopen($evidence);
|
|
|
|
return 'reopened';
|
|
}
|
|
|
|
// Already open (new or acknowledged) — unchanged
|
|
return 'unchanged';
|
|
}
|
|
|
|
Finding::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
|
'source' => 'permission_check',
|
|
'scope_key' => hash('sha256', 'permission_posture:'.$tenant->getKey()),
|
|
'fingerprint' => $fingerprint,
|
|
'subject_type' => 'permission',
|
|
'subject_external_id' => $key,
|
|
'severity' => $severity,
|
|
'status' => Finding::STATUS_NEW,
|
|
'evidence_jsonb' => $evidence,
|
|
'current_operation_run_id' => $operationRun?->getKey(),
|
|
]);
|
|
|
|
return 'created';
|
|
}
|
|
|
|
private function handleErrorPermission(
|
|
Tenant $tenant,
|
|
string $key,
|
|
string $type,
|
|
array $features,
|
|
?OperationRun $operationRun,
|
|
): void {
|
|
$fingerprint = $this->errorFingerprint($tenant, $key);
|
|
|
|
$evidence = $this->buildEvidence($key, $type, 'error', $features);
|
|
$evidence['check_error'] = true;
|
|
|
|
$existing = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('fingerprint', $fingerprint)
|
|
->first();
|
|
|
|
if ($existing instanceof Finding) {
|
|
$existing->update(['evidence_jsonb' => $evidence]);
|
|
|
|
return;
|
|
}
|
|
|
|
Finding::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
|
'source' => 'permission_check',
|
|
'scope_key' => hash('sha256', 'permission_posture_error:'.$tenant->getKey()),
|
|
'fingerprint' => $fingerprint,
|
|
'subject_type' => 'permission',
|
|
'subject_external_id' => $key,
|
|
'severity' => Finding::SEVERITY_LOW,
|
|
'status' => Finding::STATUS_NEW,
|
|
'evidence_jsonb' => $evidence,
|
|
'current_operation_run_id' => $operationRun?->getKey(),
|
|
]);
|
|
}
|
|
|
|
private function resolveExistingFinding(Tenant $tenant, string $key, string $reason): bool
|
|
{
|
|
$fingerprint = $this->fingerprint($tenant, $key);
|
|
|
|
$finding = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('fingerprint', $fingerprint)
|
|
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
|
->first();
|
|
|
|
if (! $finding instanceof Finding) {
|
|
return false;
|
|
}
|
|
|
|
$finding->resolve($reason);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resolve any open permission_posture findings whose permission_key is not
|
|
* in the current comparison (handles registry removals).
|
|
*/
|
|
private function resolveStaleFindings(Tenant $tenant, array $processedPermissionKeys): int
|
|
{
|
|
$staleFindings = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE)
|
|
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
|
->get();
|
|
|
|
$resolved = 0;
|
|
|
|
foreach ($staleFindings as $finding) {
|
|
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
|
$permissionKey = $evidence['permission_key'] ?? null;
|
|
|
|
// Skip error findings (they have check_error=true in evidence)
|
|
if (($evidence['check_error'] ?? false) === true) {
|
|
continue;
|
|
}
|
|
|
|
if ($permissionKey !== null && ! in_array($permissionKey, $processedPermissionKeys, true)) {
|
|
$finding->resolve('permission_removed_from_registry');
|
|
$resolved++;
|
|
}
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $permissions
|
|
*/
|
|
private function createStoredReport(
|
|
Tenant $tenant,
|
|
array $permissionComparison,
|
|
array $permissions,
|
|
int $postureScore,
|
|
): StoredReport {
|
|
$grantedCount = 0;
|
|
|
|
foreach ($permissions as $permission) {
|
|
if (is_array($permission) && ($permission['status'] ?? null) === 'granted') {
|
|
$grantedCount++;
|
|
}
|
|
}
|
|
|
|
return StoredReport::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
'payload' => [
|
|
'posture_score' => $postureScore,
|
|
'required_count' => count($permissions),
|
|
'granted_count' => $grantedCount,
|
|
'checked_at' => now()->toIso8601String(),
|
|
'permissions' => array_map(
|
|
static fn (array $p): array => [
|
|
'key' => $p['key'] ?? '',
|
|
'type' => $p['type'] ?? 'application',
|
|
'status' => $p['status'] ?? 'unknown',
|
|
'features' => is_array($p['features'] ?? null) ? $p['features'] : [],
|
|
],
|
|
array_filter($permissions, static fn (mixed $p): bool => is_array($p)),
|
|
),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildAlertEvent(Tenant $tenant, string $key, string $type, array $features): array
|
|
{
|
|
$event = [
|
|
'event_type' => AlertRule::EVENT_PERMISSION_MISSING,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'severity' => $this->deriveSeverity(count($features)),
|
|
'fingerprint_key' => 'permission_missing:'.$tenant->getKey().':'.$key,
|
|
'title' => 'Missing permission: '.$key,
|
|
'body' => sprintf(
|
|
'Tenant %s is missing %s. Blocked features: %s.',
|
|
$tenant->name ?? (string) $tenant->getKey(),
|
|
$key,
|
|
implode(', ', $features) ?: 'none',
|
|
),
|
|
'metadata' => [
|
|
'permission_key' => $key,
|
|
'permission_type' => $type,
|
|
'blocked_features' => $features,
|
|
],
|
|
];
|
|
|
|
$this->alertEvents[] = $event;
|
|
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildEvidence(string $key, string $type, string $actualStatus, array $features): array
|
|
{
|
|
return [
|
|
'permission_key' => $key,
|
|
'permission_type' => $type,
|
|
'expected_status' => 'granted',
|
|
'actual_status' => $actualStatus,
|
|
'blocked_features' => $features,
|
|
'checked_at' => now()->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
private function deriveSeverity(int $featureCount): string
|
|
{
|
|
return match (true) {
|
|
$featureCount >= 3 => Finding::SEVERITY_CRITICAL,
|
|
$featureCount === 2 => Finding::SEVERITY_HIGH,
|
|
$featureCount === 1 => Finding::SEVERITY_MEDIUM,
|
|
default => Finding::SEVERITY_LOW,
|
|
};
|
|
}
|
|
|
|
private function fingerprint(Tenant $tenant, string $permissionKey): string
|
|
{
|
|
return substr(hash('sha256', 'permission_posture:'.$tenant->getKey().':'.$permissionKey), 0, 64);
|
|
}
|
|
|
|
private function errorFingerprint(Tenant $tenant, string $permissionKey): string
|
|
{
|
|
return substr(hash('sha256', 'permission_posture:'.$tenant->getKey().':'.$permissionKey.':error'), 0, 64);
|
|
}
|
|
}
|