Implements Spec 104: Provider Permission Posture. What changed - Generates permission posture findings after each tenant permission compare (queued) - Stores immutable posture snapshots as StoredReports (JSONB payload) - Adds global Finding resolved lifecycle (`resolved_at`, `resolved_reason`) with `resolve()` / `reopen()` - Adds alert pipeline event type `permission_missing` (Alerts v1) and Filament option for Alert Rules - Adds retention pruning command + daily schedule for StoredReports - Adds badge mappings for `resolved` finding status and `permission_posture` finding type UX fixes discovered during manual verification - Hide “Diff” section for non-drift findings (only drift findings show diff) - Required Permissions page: “Re-run verification” now links to Tenant view (not onboarding) - Preserve Technical Details `<details>` open state across Livewire re-renders (Alpine state) Verification - Ran `vendor/bin/sail artisan test --compact --filter=PermissionPosture` (50 tests) - Ran `vendor/bin/sail artisan test --compact --filter="FindingResolved|FindingBadge|PermissionMissingAlert"` (20 tests) - Ran `vendor/bin/sail bin pint --dirty` Filament v5 / Livewire v4 compliance - Filament v5 + Livewire v4: no Livewire v3 usage. Panel provider registration (Laravel 11+) - No new panels added. Existing panel providers remain registered via `bootstrap/providers.php`. Global search rule - No changes to global-searchable resources. Destructive actions - No new destructive Filament actions were added in this PR. Assets / deploy notes - No new Filament assets registered. Existing deploy step `php artisan filament:assets` remains unchanged. Test coverage - New/updated Pest feature tests cover generator behavior, job integration, alerting, retention pruning, and resolved lifecycle. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #127
4.4 KiB
Internal Contracts: Provider Permission Posture (Spec 104)
Date: 2026-02-21 | Branch: 104-provider-permission-posture
This feature does NOT expose external HTTP APIs. All contracts are internal service interfaces.
Contract 1: PermissionPostureFindingGenerator
Service: App\Services\PermissionPosture\PermissionPostureFindingGenerator
generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult
Input:
$tenant: The tenant being evaluated$permissionComparison: Output ofTenantPermissionService::compare()— see schema below$operationRun: Optional OperationRun for tracking (created by caller if not provided)
Input schema ($permissionComparison):
array{
overall_status: 'granted'|'missing'|'error',
permissions: array<int, array{
key: string,
type: string,
description: ?string,
features: array<int, string>,
status: 'granted'|'missing'|'error',
details: ?array,
}>,
last_refreshed_at: ?string,
}
Output (PostureResult value object):
PostureResult {
int $findingsCreated,
int $findingsResolved,
int $findingsReopened,
int $findingsUnchanged,
int $errorsRecorded,
int $postureScore,
int $storedReportId,
}
Side effects:
- Creates/updates/resolves
Findingrecords (type:permission_posture) - Creates a
StoredReport(type:permission_posture) - Dispatches alert events for new/reopened findings via the alert pipeline
Idempotency: Fingerprint-based. Same input produces same findings (no duplicates).
Contract 2: PostureScoreCalculator
Service: App\Services\PermissionPosture\PostureScoreCalculator
calculate(array $permissionComparison): int
Input: $permissionComparison — same schema as Contract 1 input.
Output: Integer 0–100 representing round(granted_count / required_count * 100). Returns 100 if required_count is 0.
Pure function: No side effects, no DB access.
Contract 3: GeneratePermissionPostureFindingsJob
Job: App\Jobs\GeneratePermissionPostureFindingsJob
Constructor
public function __construct(
public readonly int $tenantId,
public readonly array $permissionComparison,
)
handle(PermissionPostureFindingGenerator $generator): void
Flow:
- Load Tenant (fail if not found)
- Skip if tenant has no active provider connection (FR-016)
- Create OperationRun (
type = permission_posture_check) - Call
$generator->generate($tenant, $permissionComparison, $run) - Record summary counts on OperationRun
- Complete OperationRun
Queue: default (same queue as health checks)
Dispatch point: ProviderConnectionHealthCheckJob::handle() after TenantPermissionService::compare() returns, when $permissionComparison['overall_status'] !== 'error'.
Contract 4: Alert Event Schema (EVENT_PERMISSION_MISSING)
Event array (passed to AlertDispatchService::dispatchEvent()):
[
'event_type' => 'permission_missing',
'tenant_id' => int,
'severity' => 'low'|'medium'|'high'|'critical',
'fingerprint_key' => "permission_missing:{tenant_id}:{permission_key}",
'title' => "Missing permission: {permission_key}",
'body' => "Tenant {tenant_name} is missing {permission_key}. Blocked features: {feature_list}.",
'metadata' => [
'permission_key' => string,
'permission_type' => string,
'blocked_features' => array<string>,
'posture_score' => int,
],
]
Produced by: PermissionPostureFindingGenerator for each newly created or re-opened finding.
Not produced for: Auto-resolved findings, existing unchanged findings, error findings.
Contract 5: EvaluateAlertsJob Extension
New method: permissionMissingEvents(): array
Query: Finding where:
finding_type = 'permission_posture'status IN ('new')(only new/re-opened, not acknowledged or resolved)severity>= minimum severity threshold from alert rulecreated_atorupdated_at>= window start
Output: Array of event arrays matching Contract 4 schema.
Contract 6: StoredReport Retention
PruneStoredReportsCommand (Artisan command)
Signature: stored-reports:prune {--days=90}
Behavior: Deletes stored_reports where created_at < now() - days. Outputs count of deleted records.
Schedule: Daily via routes/console.php.