TenantAtlas/specs/104-provider-permission-posture/research.md
ahmido ef380b67d1 feat(104): Provider Permission Posture (#127)
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
2026-02-21 22:32:52 +00:00

100 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 L116L131 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 L95L142 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