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

6.2 KiB
Raw Permalink Blame History

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