TenantAtlas/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
Ahmed Darrazi 222a7e0a97 feat(104): implement Provider Permission Posture
- 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
2026-02-21 23:31:03 +01:00

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);
}
}