TenantAtlas/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
2026-02-25 02:45:20 +01:00

477 lines
17 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;
use App\Services\Findings\FindingSlaPolicy;
use Carbon\CarbonImmutable;
/**
* 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,
private readonly FindingSlaPolicy $slaPolicy,
) {}
/**
* @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 : [];
$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<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,
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<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, 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);
}
}