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
100 lines
6.2 KiB
Markdown
100 lines
6.2 KiB
Markdown
# Research: Provider Permission Posture (Spec 104)
|
||
|
||
**Date**: 2026-02-21 | **Branch**: `104-provider-permission-posture`
|
||
|
||
## R1 — Posture Job Trigger Mechanism
|
||
|
||
**Decision**: Event-driven dispatch after `TenantPermissionService::compare()` completes.
|
||
|
||
**Rationale**: The `ProviderConnectionHealthCheckJob` already calls `compare()` at L116–L131 and has the full `$permissionComparison` result array. Dispatching `GeneratePermissionPostureFindingsJob` immediately after `compare()` within the health check job:
|
||
- Guarantees posture data freshness (always in sync with latest permission state)
|
||
- Requires no new scheduling infrastructure
|
||
- Follows the project's existing pattern of chaining work within health check jobs
|
||
|
||
**Alternatives considered**:
|
||
- Independent schedule: Rejected because posture data would lag permission checks
|
||
- Manual trigger: Rejected because it defeats the automation goal
|
||
- Laravel model events on TenantPermission: Rejected because `compare()` batch-upserts multiple records — a model event per-permission creates N dispatches instead of 1
|
||
|
||
**Hook point**: `ProviderConnectionHealthCheckJob::handle()`, after `compare()` returns at ~L131, before `TenantPermissionCheckClusters::buildChecks()`.
|
||
|
||
## R2 — Finding `resolved` Status (Global Scope)
|
||
|
||
**Decision**: `resolved` is a global Finding status, available for all finding types.
|
||
|
||
**Rationale**: The `findings.status` column is a generic string — no enum constraint. Adding `STATUS_RESOLVED = 'resolved'` to the Finding model and the `FindingStatusBadge` mapper makes the lifecycle universal. This avoids fragmenting status semantics per finding type.
|
||
|
||
**Migration requirements**:
|
||
- Add `resolved_at` (timestampTz, nullable) to `findings` table
|
||
- Add `resolved_reason` (string, nullable) to `findings` table
|
||
- Add `STATUS_RESOLVED = 'resolved'` constant to `Finding` model
|
||
- Add resolved badge mapping to `FindingStatusBadge`
|
||
- No index needed on `resolved_at` (queries filter by `status` which is already indexed)
|
||
|
||
**Alternatives considered**:
|
||
- Scoped to permission_posture only: Rejected because it fragments the status model unnecessarily
|
||
- Separate resolved_findings table: Rejected (over-engineering for a status change)
|
||
|
||
## R3 — Finding Re-open Behavior
|
||
|
||
**Decision**: Re-open existing finding by resetting status to `new` and clearing `resolved_at`/`resolved_reason`.
|
||
|
||
**Rationale**: The `[tenant_id, fingerprint]` unique constraint means only one finding per permission per tenant can exist. Re-opening preserves the original `created_at` and audit trail. The `DriftFindingGenerator` pattern at L95–L142 also uses `firstOrNew` and updates in-place.
|
||
|
||
**Implementation**: When `firstOrNew` finds a resolved finding, set `status = new`, `resolved_at = null`, `resolved_reason = null`, update `evidence_jsonb` with current state.
|
||
|
||
## R4 — Auto-resolve Scope (all statuses)
|
||
|
||
**Decision**: Auto-resolve applies to both `new` and `acknowledged` findings.
|
||
|
||
**Rationale**: A finding represents a factual state ("permission X is missing"). When the fact changes, the finding should be resolved regardless of human acknowledgement. Acknowledgement metadata (`acknowledged_at`, `acknowledged_by`) is preserved for audit — only `status`, `resolved_at`, `resolved_reason` change.
|
||
|
||
## R5 — StoredReport Table Design
|
||
|
||
**Decision**: New generic `stored_reports` table following constitution SCOPE-001 ownership rules.
|
||
|
||
**Rationale**: The constitution explicitly lists `StoredReports/Exports` as tenant-owned. The table must include `workspace_id` (NOT NULL) and `tenant_id` (NOT NULL) per SCOPE-001 database convention for tenant-owned tables. A polymorphic `report_type` string enables reuse by future domains without schema changes.
|
||
|
||
**Schema decision**: JSONB `payload` column with GIN index for future querying. No separate columns for individual payload fields — the payload structure is type-dependent.
|
||
|
||
## R6 — Alert Integration Pattern
|
||
|
||
**Decision**: Add `EVENT_PERMISSION_MISSING` constant to `AlertRule` and a `permissionPostureEvents()` method to `EvaluateAlertsJob`.
|
||
|
||
**Rationale**: The existing alert pipeline follows a clear pattern:
|
||
1. `EvaluateAlertsJob::handle()` collects events from dedicated methods
|
||
2. Each method queries recent data within a time window
|
||
3. Events are dispatched to `AlertDispatchService::dispatchEvent()`
|
||
4. No structural changes needed — just a new event type + query method
|
||
|
||
**Implementation**:
|
||
- `AlertRule::EVENT_PERMISSION_MISSING = 'permission_missing'`
|
||
- New method `EvaluateAlertsJob::permissionMissingEvents()` queries open `permission_posture` findings with severity >= minimum
|
||
- Event fingerprint: `permission_missing:{tenant_id}:{permission_key}` for cooldown dedup
|
||
|
||
## R7 — Operation Run Integration
|
||
|
||
**Decision**: Track posture generation as `OperationRun` with `type = 'permission_posture_check'`.
|
||
|
||
**Rationale**: Constitution requires OperationRun for queued jobs. `OperationCatalog` already has ~20 type constants. Adding one more follows the existing pattern.
|
||
|
||
**Implementation**: Add `TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check'` to `OperationCatalog`. The job creates/starts the run, processes all permissions for a tenant, records counts, and completes.
|
||
|
||
## R8 — Severity Derivation from Feature Impact
|
||
|
||
**Decision**: Use the `features` array from `config/intune_permissions.php` to determine severity.
|
||
|
||
**Rationale**: Each permission entry has a `features` array listing which product features depend on it. Counting blocked features maps directly to severity tiers defined in FR-005.
|
||
|
||
**Implementation**: `count($permission['features'])` → 0=low, 1=medium, 2=high, 3+=critical. This is deterministic and changes automatically when the registry is updated.
|
||
|
||
## R9 — Retention Mechanism for StoredReports
|
||
|
||
**Decision**: Scheduled artisan command that prunes reports older than configurable threshold.
|
||
|
||
**Rationale**: Standard Laravel pattern for data lifecycle management. A simple query `StoredReport::where('created_at', '<', now()->subDays($days))->delete()` in a scheduled command. Default: 90 days via `config/tenantpilot.php`.
|
||
|
||
**Alternatives considered**:
|
||
- Database TTL/partitioning: Over-engineering for the expected volume
|
||
- Soft delete: Unnecessary — reports are immutable snapshots, not user-managed records
|