# Feature Specification: Provider Permission Posture

**Feature Branch**: `104-provider-permission-posture`
**Created**: 2026-02-21
**Status**: Draft
**Input**: User description: "Provider Permission Posture - StoredReports foundation, Permission Posture Findings generation, and Alerts integration for measured app permissions"

## Spec Scope Fields *(mandatory)*

- **Scope**: tenant (per-tenant posture assessment) + workspace (alerts extend workspace-level infrastructure)
- **Primary Routes**:
  - No new Filament pages in this spec; findings and stored reports are backend data consumed by existing Findings/Alerts UI
  - Existing: Monitoring > Findings (extended with `finding_type=permission_posture`)
  - Existing: Monitoring > Alerts (extended with new `EVENT_PERMISSION_MISSING` event type)
- **Data Ownership**:
  - `stored_reports` -- tenant-owned, generic report storage (new table, workspace_id + tenant_id NOT NULL per SCOPE-001)
  - `findings` -- tenant-owned, extended with `finding_type=permission_posture` (existing table)
  - `tenant_permissions` -- tenant-owned, source of truth for measured permission state (existing table)
  - `alert_rules` -- workspace-owned, extended with `EVENT_PERMISSION_MISSING` trigger (existing table)
- **RBAC**:
  - Workspace membership is required for any access (non-members receive 404)
  - Viewing posture findings uses existing `FINDINGS_VIEW` capability
  - Acknowledging posture findings uses existing `FINDINGS_MANAGE` capability
  - Alert rules for permission events use existing `ALERTS_VIEW` / `ALERTS_MANAGE` capabilities
  - No new RBAC capabilities are introduced

## Assumptions and Dependencies

- **TenantPermissionService** already performs live permission checks via Microsoft Graph and persists results to `tenant_permissions`. That flow is unchanged; this spec reads its output.
- **DriftFindingGenerator** establishes the fingerprint-based idempotent upsert pattern for findings. The new `PermissionPostureFindingGenerator` follows the same pattern.
- **Alerts v1** (Spec 099) provides the generic `AlertDispatchService` and `EvaluateAlertsJob` framework. This spec adds a new event type; no structural changes to alerting.
- **`config/intune_permissions.php`** is the single registry of required permissions (14 entries, all `type: application`). The posture generator reads this registry to know what to expect.
- **`findings.source`** column was added in a recent migration but is not currently populated. This spec uses it to tag posture findings (`source=permission_check`).

## User Scenarios and Testing *(mandatory)*

### User Story 1 - Generate permission posture findings (Priority: P1)

As a workspace operator, after my tenant's permissions have been checked, I want the system to automatically generate findings for any missing or degraded permissions so that I can see permission gaps alongside drift findings.

**Why this priority**: This is the core value. Without posture findings, nothing downstream (alerts, reports) has data to work with.

**Independent Test**: Run the posture finding generator for a tenant with 2 missing permissions; confirm 2 findings of type `permission_posture` exist with severity, fingerprint, and evidence populated.

**Acceptance Scenarios**:

1. **Given** a tenant has 14 required permissions and 12 are granted, **When** the posture finding generator runs, **Then** 2 findings are created with `finding_type=permission_posture`, `status=new`, and `severity` based on the number of features blocked.
2. **Given** a tenant had a missing permission that is now granted, **When** the posture finding generator runs again, **Then** the previously-created finding for that permission is auto-resolved (status changes to `resolved`, `resolved_at` and `resolved_reason` recorded).
3. **Given** a tenant has all required permissions granted, **When** the posture finding generator runs, **Then** no new findings are created; any previously open posture findings are auto-resolved.
4. **Given** an operator has acknowledged a posture finding and the permission is later re-granted, **When** the posture finding generator runs, **Then** the finding is auto-resolved (acknowledged status does not block auto-resolve); acknowledgement metadata is preserved.
5. **Given** the posture generator runs twice for the same permission state, **When** the same missing permissions persist, **Then** no duplicate findings are created (fingerprint-based idempotency).
6. **Given** a permission finding was auto-resolved (permission was granted) and then the permission is revoked again, **When** the posture generator runs, **Then** the existing finding is re-opened (status set to `new`, `resolved_at`/`resolved_reason` cleared, evidence updated).

---

### User Story 2 - Persist posture snapshot as a stored report (Priority: P2)

As a workspace operator, I want each permission check to produce a durable posture snapshot (stored report) so that I can track permission health over time and answer "what was the posture at time T?"

**Why this priority**: Stored reports provide temporal context and form the foundation for future dashboards and trend analysis.

**Independent Test**: Run the posture check for a tenant; confirm a stored report record exists with the correct report type, payload schema, and associated tenant.

**Acceptance Scenarios**:

1. **Given** a tenant permission check completes, **When** the stored report is created, **Then** it contains the full posture payload (required permissions, granted statuses, computed score, timestamp) and is associated with the tenant and workspace.
2. **Given** multiple posture checks run over time, **When** I query stored reports for a tenant, **Then** I can see the history of snapshots ordered by creation date.
3. **Given** the stored reports table has a polymorphic type field, **When** other spec domains later produce reports, **Then** they can use the same table without schema changes.

---

### User Story 3 - Alert on missing permissions (Priority: P3)

As a workspace manager, I want to be notified via existing alert channels (Teams/email) when a tenant is missing critical permissions, so I can take corrective action before operations fail.

**Why this priority**: Alerts close the feedback loop. Operators learn about permission gaps without polling the UI.

**Independent Test**: Create an alert rule for `EVENT_PERMISSION_MISSING`, run the posture generator for a tenant with a missing high-impact permission, and confirm a delivery is queued for the matching rule.

**Acceptance Scenarios**:

1. **Given** an alert rule exists for `EVENT_PERMISSION_MISSING` with minimum severity = high, **When** a posture finding of severity high is created, **Then** the alert dispatch service queues a delivery for each enabled destination on that rule.
2. **Given** an alert rule exists for `EVENT_PERMISSION_MISSING` with minimum severity = critical, **When** a posture finding of severity high (not critical) is created, **Then** no delivery is queued for that rule.
3. **Given** the same permission is still missing across two posture runs, **When** the alert is evaluated, **Then** cooldown/dedupe logic from Alerts v1 prevents duplicate notifications (fingerprint-based suppression).

---

### User Story 4 - Posture score calculation (Priority: P2)

As a workspace operator, I want a normalized posture score (0-100) for each tenant that summarizes how many required permissions are granted versus missing, so I can quickly compare tenant health at a glance.

**Why this priority**: A single numeric score enables sorting, filtering, and future dashboard widgets.

**Independent Test**: Check a tenant with 12/14 permissions granted; confirm score = 86 (round(12/14 * 100)). Check a tenant with 14/14; confirm score = 100.

**Acceptance Scenarios**:

1. **Given** a tenant has N of M required permissions granted, **When** the posture score is calculated, **Then** the score equals `round(N / M * 100)`.
2. **Given** the registry has 0 required permissions (edge case), **When** the score is calculated, **Then** the score is 100 (no requirements = fully compliant).
3. **Given** the posture score is stored in the report payload, **When** users query stored reports, **Then** they can sort/filter tenants by posture score.

---

### Edge Cases

- **Graph API unreachable during posture check**: If TenantPermissionService returns an error state for a permission, the posture generator does NOT create a missing-permission finding for that key. Instead, it creates a finding with `finding_type=permission_posture` and `check_error=true` in evidence (per FR-015), using a distinct error fingerprint.
- **Registry changes (permissions added/removed)**: If a new permission is added to `config/intune_permissions.php`, the next posture run automatically detects it as missing (for tenants that don't have it). If a permission is removed from the registry, existing findings for that key are auto-resolved on the next run.
- **Tenant with no provider connection**: The posture generator skips tenants without a configured provider connection; no findings or reports are created.
- **Concurrent posture runs**: Fingerprint-based upsert ensures idempotency. Two concurrent runs for the same tenant produce the same set of findings without duplicates.
- **Large workspace with many tenants**: The posture check is dispatched per-tenant as a queued job (event-driven after `compare()`), not as a blocking batch operation.

## Requirements *(mandatory)*

**Constitution alignment (required):** This feature introduces long-running/queued work (posture generator job).
- **Contract registry**: No new Graph contracts; uses existing TenantPermissionService read path.
- **Safety gates**: Posture generation is a read-only analysis; no write operations to Intune. No confirmation needed.
- **Tenant isolation**: Findings and stored reports are always scoped to a specific tenant (via `tenant_id` FK). Workspace-level queries filter by tenant entitlement.
- **Run observability**: The posture generator runs as a queued job. Its execution is tracked by the existing `OperationRun` mechanism with `type=permission_posture_check`. Status, outcome, started_at, completed_at are recorded.
- **Tests**: Unit tests for finding generation logic, score calculation, auto-resolve, and alert event production.

**Constitution alignment (RBAC-UX):** This feature does not introduce new authorization behavior.
- **Authorization planes**: Tenant-context (`/admin/t/{tenant}/...`) for viewing tenant posture findings; workspace-context for alerts.
- **404 vs 403**: Non-member / not entitled to workspace or tenant scope results in 404. Member missing `FINDINGS_VIEW` results in 403.
- **Server-side enforcement**: Existing FindingPolicy and AlertRule policies apply. No new policies needed.
- **Capability registry**: Uses `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE` -- all existing.
- **Global search**: No new globally searchable resources.
- **Destructive actions**: None. Posture findings are system-generated, not user-deletable.
- **Authorization tests**: **Exemption** — no new policies, gates, or authorization surfaces are introduced. Posture findings use existing `FindingPolicy` and `FINDINGS_VIEW`/`FINDINGS_MANAGE` capabilities. Existing authorization tests in Findings resource cover this. If future specs add posture-specific authorization surfaces, add dedicated tests at that time.

**Constitution alignment (OPS-EX-AUTH-001):** Not applicable -- no OIDC/SAML login flows involved.

**Constitution alignment (BADGE-001):** This feature introduces new badge values:
- `finding_type=permission_posture` -- new finding type value. Badge rendering uses the centralized finding-type badge map; the new type is added there with icon and color.
- `severity` values: Uses existing `low`, `medium`, `high`, `critical` -- no new severity values.
- `status` values: Uses existing `new`, `acknowledged` + new `resolved` status for auto-closed findings. The `resolved` status is added to the centralized badge map.
- Tests cover rendering of `permission_posture` type badge and `resolved` status badge.

**Constitution alignment (Filament Action Surfaces):** No new Filament Resources/Pages/RelationManagers are introduced. Posture findings appear in the existing Findings list with the new `finding_type` filter value. **Exemption**: No UI Action Matrix needed -- no new action surfaces.

**Constitution alignment (UX-001 -- Layout and Information Architecture):** No new Filament screens. Posture findings use the existing Findings UI which already complies with UX-001. **Exemption**: No layout changes to audit.

### Functional Requirements

- **FR-001 (Stored Reports Table)**: The system MUST provide a generic `stored_reports` table with a polymorphic `report_type` field, a JSONB `payload` column, and foreign keys to `tenant_id` and `workspace_id`. This table is reusable by future spec domains.
- **FR-002 (Posture Payload Schema)**: Each stored posture report MUST contain: `report_type=permission_posture`, a payload with `required_permissions` (from registry), `granted_statuses` (per-key status at check time), `posture_score` (integer 0-100), and `checked_at` timestamp.
- **FR-003 (Posture Score Calculation)**: The system MUST calculate posture score as `round(granted_count / required_count * 100)`. If `required_count` is 0, score MUST be 100.
- **FR-004 (Posture Finding Generation)**: For each permission that is `status=missing` after a tenant permission check, the system MUST create or update a finding with `finding_type=permission_posture`, a deterministic fingerprint based on `tenant_id + permission_key`, and severity derived from the number of features that depend on that permission. Posture findings MUST set `subject_type='permission'` and `subject_external_id` to the permission key for uniform entity referencing.
- **FR-005 (Severity Derivation)**: Finding severity MUST be derived from feature impact:
  - Permission blocks 3+ features results in `critical`
  - Permission blocks 2 features results in `high`
  - Permission blocks 1 feature results in `medium`
  - Permission blocks 0 features results in `low`
- **FR-006 (Finding Evidence)**: Each posture finding MUST store evidence in `evidence_jsonb` containing at minimum: `permission_key`, `permission_type`, `expected_status`, `actual_status`, `blocked_features` (list), and `checked_at`.
- **FR-007 (Finding Source Tag)**: Posture findings MUST set `source=permission_check` on the `findings` table to distinguish them from drift findings.
- **FR-008 (Auto-Resolve)**: When a previously-missing permission is now granted, the system MUST auto-resolve the corresponding finding by changing its status to `resolved` and recording `resolved_at` and `resolved_reason=permission_granted`. Auto-resolve applies regardless of current status: both `new` and `acknowledged` findings transition to `resolved`. Acknowledgement metadata (`acknowledged_at`, `acknowledged_by`) is preserved on the record for audit. The `resolved` status, `resolved_at`, and `resolved_reason` columns are global additions to the `findings` table (usable by all finding types, not just `permission_posture`).
- **FR-009 (Idempotent Upsert)**: The posture generator MUST use fingerprint-based upsert (`firstOrNew` on `tenant_id + fingerprint`) to prevent duplicate findings for the same permission on the same tenant. If a resolved finding is matched (permission became missing again), the system MUST re-open it: set status to `new`, clear `resolved_at` and `resolved_reason`, and update `evidence_jsonb` to reflect the current state.
- **FR-010 (Alert Event)**: When posture findings are created or re-opened, the system MUST produce `EVENT_PERMISSION_MISSING` events for the alert dispatch pipeline. Events MUST include tenant_id, permission_key, severity, and a deterministic fingerprint for cooldown/dedupe. Resolved or unchanged findings do NOT produce alert events.
- **FR-011 (Alert Rule Integration)**: The `EVENT_PERMISSION_MISSING` event type MUST be available as an option when creating/editing alert rules in the existing Alerts UI. No new alert pages are needed.
- **FR-012 (Queued Execution)**: Posture generation MUST execute as a queued job (per-tenant), dispatched automatically after each `TenantPermissionService::compare()` completes (event-driven). No independent schedule or manual trigger is required.
- **FR-013 (Operation Run Tracking)**: Each posture generation execution MUST be tracked as an `OperationRun` with `type=permission_posture_check`, recording start, completion, outcome (success/failure), and error details.
- **FR-014 (Tenant Isolation)**: Findings, stored reports, and operation runs MUST be scoped to a specific tenant via `tenant_id`. Cross-tenant data access MUST be impossible at the query level.
- **FR-015 (Error Handling)**: If the permission check returns an error state for a specific permission key, the system MUST NOT create a `missing` finding for that key. Instead, it MUST create a finding with `finding_type=permission_posture` and error evidence in `evidence_jsonb` containing: `check_error=true`, `error_message` (string), `permission_key`, and `checked_at`. Error findings are NOT required to include `expected_status`, `actual_status`, or `blocked_features` from FR-006. The finding uses fingerprint `sha256("permission_posture:{tenant_id}:{permission_key}:error")` (distinct from the normal missing-permission fingerprint). Severity for error findings defaults to `low`.
- **FR-016 (Skip Unconnected Tenants)**: The posture generator MUST skip tenants that have no configured provider connection. No findings or reports are created for unconnected tenants.
- **FR-017 (Registry as Source)**: The set of required permissions MUST be read from `config/intune_permissions.php` at runtime. No hardcoded permission lists in the generator.
- **FR-018 (Retention)**: Stored reports MUST support configurable retention. Default: 90 days.

## UI Action Matrix *(mandatory when Filament is changed)*

**Exemption**: This spec does NOT add or modify any Filament Resource, RelationManager, or Page.

Posture findings are rendered via the existing Findings Resource with a new `finding_type` filter value (`permission_posture`). The `EVENT_PERMISSION_MISSING` option is added to the existing Alert Rules form event type dropdown. No new surfaces, actions, or pages are created.

### Key Entities *(include if feature involves data)*

- **StoredReport**: A generic, tenant-owned report record (workspace_id + tenant_id NOT NULL per SCOPE-001). Polymorphic `report_type` distinguishes domain (e.g., `permission_posture`). JSONB `payload` holds the full report data. Supports temporal queries (ordered by `created_at`).
- **Finding (extended)**: Existing model extended with `finding_type=permission_posture` and `source=permission_check`. Each posture finding has a deterministic fingerprint (`tenant_id + permission_key`), severity derived from feature impact, and structured evidence in `evidence_jsonb`. Posture findings set `subject_type='permission'` and `subject_external_id={permission_key}` for uniform entity referencing across finding types. The `resolved` status (plus `resolved_at` timestampTz and `resolved_reason` string columns) is added globally to the Finding model — all finding types (drift, permission_posture, future) can use this lifecycle state.
- **AlertRule (extended)**: Existing model gains `EVENT_PERMISSION_MISSING` as a valid `event_type` constant. No schema change; the constant is added to the model and the UI dropdown.

## Clarifications

### Session 2026-02-21

- Q: What dispatches the posture generation job? → A: Event-driven — dispatched automatically after each `TenantPermissionService::compare()` completes.
- Q: Does `resolved` status apply globally to all finding types or only permission_posture? → A: Global — `resolved` is a valid status for ALL finding types (drift, permission_posture, future types). The `resolved_at` and `resolved_reason` columns are added to the `findings` table as nullable columns usable by any finding type.
- Q: When a resolved finding's permission becomes missing again, re-open or create new? → A: Re-open — set status back to `new`, clear `resolved_at`/`resolved_reason`, update evidence to current state. Preserves history and avoids stale resolved records accumulating.
- Q: Can an `acknowledged` finding be auto-resolved when the permission is re-granted? → A: Yes — auto-resolve applies regardless of current status (`new` → `resolved` and `acknowledged` → `resolved`). The finding represents a factual state; when the fact changes, the finding is resolved. Acknowledgement history (`acknowledged_at`, `acknowledged_by`) is preserved for audit.

## Success Criteria *(mandatory)*

### Measurable Outcomes

- **SC-001 (Posture coverage)**: For any tenant with a configured provider connection, 100% of required permissions from the registry are evaluated and their status reflected in the posture report and findings.
- **SC-002 (Finding accuracy)**: The number of open `permission_posture` findings for a tenant exactly matches the number of `missing` permissions reported by the tenant permission check (no duplicates, no omissions).
- **SC-003 (Auto-resolve latency)**: When a previously-missing permission is granted, the corresponding finding is auto-resolved within the next posture check cycle (no manual intervention needed).
- **SC-004 (Score reliability)**: Posture score for a tenant with N of M permissions granted equals `round(N / M * 100)`. Deterministic and reproducible.
- **SC-005 (Alert delivery)**: When a posture finding of qualifying severity is created and an active alert rule matches, a delivery is queued within the standard alert processing time (under 2 minutes per Alerts v1 SLA).
- **SC-006 (Temporal audit)**: An operator can query stored reports to see a tenant's posture at any point in the last 90 days.
- **SC-007 (No duplicates)**: Repeated posture checks for the same permission state on the same tenant produce no duplicate findings (fingerprint idempotency verified).
