, status: string, ...}>, ...} $permissionComparison */ public function generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult { $permissions = $permissionComparison['permissions'] ?? []; $permissions = is_array($permissions) ? $permissions : []; $observedAt = $this->resolveObservedAt($permissionComparison, $operationRun); $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, $observedAt, $operationRun); $errors++; continue; } if ($status === 'missing') { $result = $this->handleMissingPermission($tenant, $key, $type, $features, $observedAt, $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', $observedAt)) { $resolved++; } } // Step 9: Resolve stale findings for permissions removed from registry $resolved += $this->resolveStaleFindings($tenant, $processedPermissionKeys, $observedAt); $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> */ public function getAlertEvents(): array { return $this->alertEvents; } /** @var array> */ private array $alertEvents = []; private function handleMissingPermission( Tenant $tenant, string $key, string $type, array $features, CarbonImmutable $observedAt, ?OperationRun $operationRun, ): string { $fingerprint = $this->fingerprint($tenant, $key); $evidence = $this->buildEvidence($key, $type, 'missing', $features, $observedAt); $severity = $this->deriveSeverity(count($features)); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->where('fingerprint', $fingerprint) ->first(); if ($finding instanceof Finding) { $this->observeFinding($finding, $observedAt); $finding->forceFill([ 'severity' => $severity, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), ]); if ($finding->status === Finding::STATUS_RESOLVED) { $resolvedAt = $finding->resolved_at; if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); $finding->forceFill([ 'status' => Finding::STATUS_REOPENED, 'reopened_at' => $observedAt, 'resolved_at' => null, 'resolved_reason' => null, 'closed_at' => null, 'closed_reason' => null, 'closed_by_user_id' => null, 'sla_days' => $slaDays, 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); $finding->save(); return 'reopened'; } } // Already open (new or acknowledged) — unchanged $finding->save(); return 'unchanged'; } $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); 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(), 'first_seen_at' => $observedAt, 'last_seen_at' => $observedAt, 'times_seen' => 1, 'sla_days' => $slaDays, 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); return 'created'; } private function handleErrorPermission( Tenant $tenant, string $key, string $type, array $features, CarbonImmutable $observedAt, ?OperationRun $operationRun, ): void { $fingerprint = $this->errorFingerprint($tenant, $key); $evidence = $this->buildEvidence($key, $type, 'error', $features, $observedAt); $evidence['check_error'] = true; $severity = Finding::SEVERITY_LOW; $existing = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->where('fingerprint', $fingerprint) ->first(); if ($existing instanceof Finding) { $this->observeFinding($existing, $observedAt); $existing->forceFill([ 'severity' => $severity, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), ]); if ($existing->status === Finding::STATUS_RESOLVED) { $resolvedAt = $existing->resolved_at; if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) { $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); $existing->forceFill([ 'status' => Finding::STATUS_REOPENED, 'reopened_at' => $observedAt, 'resolved_at' => null, 'resolved_reason' => null, 'closed_at' => null, 'closed_reason' => null, 'closed_by_user_id' => null, 'sla_days' => $slaDays, 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); } } $existing->save(); return; } $slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant); 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' => $severity, 'status' => Finding::STATUS_NEW, 'evidence_jsonb' => $evidence, 'current_operation_run_id' => $operationRun?->getKey(), 'first_seen_at' => $observedAt, 'last_seen_at' => $observedAt, 'times_seen' => 1, 'sla_days' => $slaDays, 'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt), ]); } private function resolveExistingFinding(Tenant $tenant, string $key, string $reason, CarbonImmutable $observedAt): bool { $fingerprint = $this->fingerprint($tenant, $key); $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->where('fingerprint', $fingerprint) ->whereIn('status', Finding::openStatusesForQuery()) ->first(); if (! $finding instanceof Finding) { return false; } $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => $observedAt, 'resolved_reason' => $reason, ])->save(); 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, CarbonImmutable $observedAt): int { $staleFindings = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_PERMISSION_POSTURE) ->whereIn('status', Finding::openStatusesForQuery()) ->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->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => $observedAt, 'resolved_reason' => 'permission_removed_from_registry', ])->save(); $resolved++; } } return $resolved; } private function resolveObservedAt(array $comparison, ?OperationRun $operationRun): CarbonImmutable { if ($operationRun?->completed_at !== null) { return CarbonImmutable::instance($operationRun->completed_at); } $refreshedAt = $comparison['last_refreshed_at'] ?? null; if (is_string($refreshedAt) && trim($refreshedAt) !== '') { try { return CarbonImmutable::parse($refreshedAt); } catch (\Throwable) { // Fall through. } } return CarbonImmutable::now(); } private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } $lastSeenAt = $finding->last_seen_at; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { $finding->last_seen_at = $observedAt; $finding->times_seen = max(0, $timesSeen) + 1; return; } if ($timesSeen < 1) { $finding->times_seen = 1; } } /** * @param array> $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 */ 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 */ private function buildEvidence(string $key, string $type, string $actualStatus, array $features, CarbonImmutable $observedAt): array { return [ 'permission_key' => $key, 'permission_type' => $type, 'expected_status' => 'granted', 'actual_status' => $actualStatus, 'blocked_features' => $features, 'checked_at' => $observedAt->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); } }