- T001-T014: Foundation - StoredReport model/migration, Finding resolved lifecycle, badge mappings (resolved status, permission_posture type), OperationCatalog + AlertRule constants - T015-T022: US1 - PermissionPostureFindingGenerator with fingerprint-based idempotent upsert, severity from feature-impact count, auto-resolve on grant, auto-reopen on revoke, error findings (FR-015), stale finding cleanup; GeneratePermissionPostureFindingsJob dispatched from health check; PostureResult VO + PostureScoreCalculator - T023-T026: US2+US4 - Stored report payload validation, temporal ordering, polymorphic reusability, score accuracy acceptance tests - T027-T029: US3 - EvaluateAlertsJob.permissionMissingEvents() wired into alert pipeline, AlertRuleResource event type option, cooldown/dedupe tests - T030-T034: Polish - PruneStoredReportsCommand with config retention, scheduled daily, end-to-end integration test, Pint clean UI bug fixes found during testing: - FindingResource: hide Diff section for non-drift findings - TenantRequiredPermissions: fix re-run verification link - tenant-required-permissions.blade.php: preserve details open state 70 tests (50 PermissionPosture + 20 FindingResolved/Badge/Alert), 216 assertions
6.2 KiB
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) tofindingstable - Add
resolved_reason(string, nullable) tofindingstable - Add
STATUS_RESOLVED = 'resolved'constant toFindingmodel - Add resolved badge mapping to
FindingStatusBadge - No index needed on
resolved_at(queries filter bystatuswhich 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:
EvaluateAlertsJob::handle()collects events from dedicated methods- Each method queries recent data within a time window
- Events are dispatched to
AlertDispatchService::dispatchEvent() - No structural changes needed — just a new event type + query method
Implementation:
AlertRule::EVENT_PERMISSION_MISSING = 'permission_missing'- New method
EvaluateAlertsJob::permissionMissingEvents()queries openpermission_posturefindings 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