# Tasks: Provider Permission Posture

**Input**: Design documents from `/specs/104-provider-permission-posture/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/internal-services.md, quickstart.md

**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). All user stories include test tasks.
**Operations**: This feature introduces queued work (`GeneratePermissionPostureFindingsJob`). Tasks include `OperationRun` creation and outcome tracking per constitution.
**RBAC**: No new capabilities introduced. Uses existing `FINDINGS_VIEW`, `FINDINGS_MANAGE`, `ALERTS_VIEW`, `ALERTS_MANAGE`. No new Gate/Policy needed. **Authorization tests exemption** — no new authorization surfaces or policies; existing FindingPolicy and AlertRulePolicy coverage applies (see spec Constitution alignment RBAC-UX).
**Filament UI Action Surfaces**: **Exemption** — no new Filament Resources/Pages/RelationManagers. Only change is adding a new option to `AlertRuleResource::eventTypeOptions()`.
**Filament UI UX-001**: **Exemption** — no new screens.
**Badges**: Adds `resolved` status to `FindingStatusBadge` and creates `FindingTypeBadge` for `permission_posture` per BADGE-001. Tests included.

**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.

## Format: `[ID] [P?] [Story] Description`

- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4)
- Include exact file paths in descriptions

---

## Phase 1: Setup

**Purpose**: No project setup needed — existing Laravel project with all framework dependencies.

*Phase skipped.*

---

## Phase 2: Foundation (Blocking Prerequisites)

**Purpose**: Migrations, models, constants, and badge mappings that ALL user stories depend on.

**CRITICAL**: No user story work can begin until this phase is complete.

### Migrations

- [X] T001 Create migration for `stored_reports` table via `vendor/bin/sail artisan make:migration create_stored_reports_table` — columns: id (PK), workspace_id (FK→workspaces, NOT NULL), tenant_id (FK→tenants, NOT NULL), report_type (string, NOT NULL), payload (jsonb, NOT NULL), timestamps; indexes: composite on [workspace_id, tenant_id, report_type], [tenant_id, created_at], GIN on payload. See `specs/104-provider-permission-posture/data-model.md` for full schema.
- [X] T002 [P] Create migration to add `resolved_at` (timestampTz, nullable) and `resolved_reason` (string(255), nullable) to `findings` table via `vendor/bin/sail artisan make:migration add_resolved_to_findings_table`. These are global columns usable by all finding types per spec clarification.

### Models & Factories

- [X] T003 [P] Create `StoredReport` model in `app/Models/StoredReport.php` with: `REPORT_TYPE_PERMISSION_POSTURE` constant, `DerivesWorkspaceIdFromTenant` + `HasFactory` traits, fillable `[workspace_id, tenant_id, report_type, payload]`, `casts()` method with `payload → array`, `workspace()` BelongsTo and `tenant()` BelongsTo relationships. See `specs/104-provider-permission-posture/data-model.md` StoredReport model section.
- [X] T004 [P] Create `StoredReportFactory` in `database/factories/StoredReportFactory.php` with default state: `tenant_id → Tenant::factory()`, `report_type → StoredReport::REPORT_TYPE_PERMISSION_POSTURE`, `payload` containing sample posture data (posture_score, required_count, granted_count, checked_at, permissions array). See `specs/104-provider-permission-posture/data-model.md` payload schema.
- [X] T005 Extend `Finding` model in `app/Models/Finding.php`: add `STATUS_RESOLVED = 'resolved'` constant, `FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture'` constant (error findings use the same type, distinguished by `check_error=true` in evidence per FR-015 — no separate constant needed), `resolved_at` datetime cast in `casts()` method, `resolve(string $reason): void` method (sets status, resolved_at, resolved_reason, saves), `reopen(array $evidence): void` method (sets status=new, clears resolved_at/resolved_reason, updates evidence_jsonb, saves). See `specs/104-provider-permission-posture/data-model.md` Finding model section.
- [X] T006 Add `permissionPosture()` and `resolved()` factory states to `database/factories/FindingFactory.php`. `permissionPosture()` sets: `finding_type → Finding::FINDING_TYPE_PERMISSION_POSTURE`, `source → 'permission_check'`, `severity → Finding::SEVERITY_MEDIUM`, sample permission evidence in `evidence_jsonb`. `resolved()` sets: `status → Finding::STATUS_RESOLVED`, `resolved_at → now()`, `resolved_reason → 'permission_granted'`.

### Constants & Badge Mappings

- [X] T007 [P] Add `TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check'` constant to `app/Support/OperationCatalog.php`
- [X] T008 [P] Add `EVENT_PERMISSION_MISSING = 'permission_missing'` constant to `app/Models/AlertRule.php`
- [X] T009 [P] Add `resolved` status badge mapping (color: `success`, icon: `heroicon-o-check-circle`) to `app/Support/Badges/Domains/FindingStatusBadge.php`
- [X] T010 [P] Create `FindingTypeBadge` in `app/Support/Badges/Domains/FindingTypeBadge.php` following the pattern of `FindingStatusBadge.php` / `FindingSeverityBadge.php`. Map `drift → info`, `permission_posture → warning`. Register in badge catalog per BADGE-001.

### Verify & Test Foundation

- [X] T011 Run migrations via `vendor/bin/sail artisan migrate` and verify `stored_reports` table and `findings` column additions exist
- [X] T012 [P] Write StoredReport model CRUD tests in `tests/Feature/PermissionPosture/StoredReportModelTest.php`: create with factory, verify relationships (tenant, workspace), verify payload cast to array, verify report_type constant
- [X] T013 [P] Write Finding resolved lifecycle tests in `tests/Feature/Models/FindingResolvedTest.php`: `resolve()` sets status/resolved_at/resolved_reason, `reopen()` resets to new/clears resolved fields/updates evidence, resolve from `acknowledged` preserves acknowledged_at/acknowledged_by
- [X] T014 [P] Write badge rendering tests for `resolved` status and `permission_posture` finding type in `tests/Feature/Support/Badges/FindingBadgeTest.php`: resolved status renders success color, permission_posture type renders correct badge

**Checkpoint**: Foundation ready — all models, migrations, constants, and badges in place. User story implementation can begin.

---

## Phase 3: User Story 1 — Generate Permission Posture Findings (Priority: P1) 🎯 MVP

**Goal**: After a tenant's permissions are checked, automatically generate findings for missing/degraded permissions with severity, fingerprint, and evidence. Auto-resolve when permissions are granted. Re-open when revoked again.

**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.

### Implementation for User Story 1

- [X] T015 [P] [US1] Create `PostureResult` value object in `app/Services/PermissionPosture/PostureResult.php` with readonly properties: `findingsCreated`, `findingsResolved`, `findingsReopened`, `findingsUnchanged`, `errorsRecorded`, `postureScore`, `storedReportId`. See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 1 output.
- [X] T016 [P] [US1] Create `PostureScoreCalculator` service in `app/Services/PermissionPosture/PostureScoreCalculator.php` with `calculate(array $permissionComparison): int` — returns `round(granted / required * 100)`, returns 100 when required_count is 0. Pure function, no DB access. See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 2.
- [X] T017 [US1] Create `PermissionPostureFindingGenerator` service in `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` with `generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult`. Implementation must: (1) iterate permissions from comparison, (2) for `status=missing`: create/reopen findings via fingerprint upsert (`firstOrNew` on `[tenant_id, fingerprint]`), (3) for `status=granted`: auto-resolve existing open findings, (4) for `status=error`: create error findings with `check_error=true` in evidence and distinct fingerprint `sha256("permission_posture:{tenant_id}:{permission_key}:error")` per FR-015, (5) compute severity from feature count (3+→critical, 2→high, 1→medium, 0→low per FR-005; error findings default to `low`), (6) set `subject_type='permission'` and `subject_external_id={permission_key}` on all posture findings, (7) create StoredReport with posture payload, (8) produce alert events for new/reopened findings. Fingerprint for missing: `sha256("permission_posture:{tenant_id}:{permission_key}")` truncated to 64 chars. (9) After processing all permissions from comparison, resolve any remaining open `permission_posture` findings for this tenant whose `permission_key` (from evidence) is NOT present in the current comparison — this handles registry removals per edge case "Registry changes". See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 1 and `specs/104-provider-permission-posture/data-model.md`.
- [X] T018 [US1] Create `GeneratePermissionPostureFindingsJob` in `app/Jobs/GeneratePermissionPostureFindingsJob.php` with constructor accepting `int $tenantId` and `array $permissionComparison`. `handle()` must: (1) load Tenant or fail, (2) skip if no active provider connection (FR-016), (3) create OperationRun with `type=permission_posture_check`, (4) call `PermissionPostureFindingGenerator::generate()`, (5) record summary counts on OperationRun, (6) complete OperationRun. See `specs/104-provider-permission-posture/contracts/internal-services.md` Contract 3.
- [X] T019 [US1] Modify `app/Jobs/ProviderConnectionHealthCheckJob.php` to dispatch `GeneratePermissionPostureFindingsJob` after `TenantPermissionService::compare()` returns at ~L131, only when `$permissionComparison['overall_status'] !== 'error'`. Pass tenant ID and full comparison result to the job.

### Tests for User Story 1

- [X] T020 [P] [US1] Write PostureScoreCalculator tests in `tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php`: all granted → 100, 12 of 14 → 86, all missing → 0, 0 required → 100, single permission granted → 100, single permission missing → 0
- [X] T021 [US1] Write PermissionPostureFindingGenerator tests in `tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`: (1) creates findings for missing permissions with correct type/severity/fingerprint/evidence/source, (2) auto-resolves finding when permission granted (status→resolved, resolved_at set, resolved_reason='permission_granted'), (3) auto-resolves acknowledged finding (preserves acknowledged_at/acknowledged_by), (4) no duplicates on idempotent run (same missing permissions → same findings), (5) re-opens resolved finding when permission revoked again (status→new, cleared resolved fields, updated evidence), (6) creates error finding for status=error permissions with `check_error=true` in evidence and distinct fingerprint (FR-015), (7) severity derivation: 0 features→low, 1→medium, 2→high, 3+→critical, (8) creates StoredReport with correct payload, (9) no findings when all granted (existing opened findings are resolved), (10) produces alert events for new and reopened findings only, (11) generator reads permissions from `config('intune_permissions')` at runtime — not hardcoded (FR-017), (12) all findings are scoped to tenant_id via FK constraint (FR-014), (13) subject_type='permission' and subject_external_id={permission_key} set on every posture finding, (14) stale findings for permissions removed from registry are auto-resolved (open finding whose permission_key is not in current comparison → resolved with reason='permission_removed_from_registry')
- [X] T022 [US1] Write GeneratePermissionPostureFindingsJob tests in `tests/Feature/PermissionPosture/GeneratePermissionPostureFindingsJobTest.php`: (1) successful run creates OperationRun with type=permission_posture_check and outcome=success, (2) skips tenant without provider connection (no findings, no report, no OperationRun), (3) records summary counts on OperationRun (findings_created, findings_resolved, etc.), (4) handles generator exceptions gracefully (OperationRun marked failed), (5) dispatched from ProviderConnectionHealthCheckJob after successful compare

**Checkpoint**: US1 complete — posture findings are generated, auto-resolved, re-opened, and tracked via OperationRun. This is the MVP.

---

## Phase 4: User Story 4 — Posture Score Calculation (Priority: P2) + User Story 2 — Persist Posture Snapshot (Priority: P2)

**Goal (US4)**: Provide a normalized posture score (0-100) for each tenant summarizing permission health.
**Goal (US2)**: Each permission check produces a durable posture snapshot (stored report) for temporal queries.

**Independent Test (US4)**: Tenant with 12/14 permissions → score = 86. Tenant with 14/14 → score = 100.
**Independent Test (US2)**: Run posture check; confirm stored report exists with correct report_type, payload schema, and tenant association.

*Note: PostureScoreCalculator is implemented in Phase 3 (US1 dependency). StoredReport creation is within the generator (Phase 3). This phase adds dedicated acceptance tests for US2 and US4 scenarios.*

### Tests for User Story 4

- [X] T023 [P] [US4] Extend PostureScoreCalculator tests in `tests/Feature/PermissionPosture/PostureScoreCalculatorTest.php` with acceptance scenarios: exact rounding verification for various N/M combinations (1/3→33, 2/3→67, 7/14→50), confirm score is integer not float

### Tests for User Story 2

- [X] T024 [US2] Extend StoredReport tests (file created by T012: `tests/Feature/PermissionPosture/StoredReportModelTest.php`) with generator integration scenarios: (1) report created by generator has report_type='permission_posture' with payload containing posture_score, required_count, granted_count, checked_at, and permissions array, (2) posture_score in payload matches PostureScoreCalculator output
- [X] T025 [P] [US2] Test temporal ordering in `tests/Feature/PermissionPosture/StoredReportModelTest.php` (extend file from T012): multiple posture reports for same tenant ordered by created_at descending, queryable by tenant_id + report_type
- [X] T026 [P] [US2] Test polymorphic reusability in `tests/Feature/PermissionPosture/StoredReportModelTest.php` (extend file from T012): create StoredReport with different report_type value, confirm coexists with permission_posture reports and is independently queryable

**Checkpoint**: US2 + US4 complete — posture scores are accurate and stored reports provide temporal audit trail.

---

## Phase 5: User Story 3 — Alert on Missing Permissions (Priority: P3)

**Goal**: Notify operators via existing alert channels when a tenant is missing critical permissions, using the Alerts v1 pipeline.

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

### Implementation for User Story 3

- [X] T027 [US3] Add `permissionMissingEvents(): array` method to `app/Jobs/Alerts/EvaluateAlertsJob.php` — queries `Finding` where `finding_type='permission_posture'`, `status IN ('new')`, within the time window (filter by `updated_at`, not `created_at`, to capture re-opened findings whose original creation predates the window). Returns array of event arrays matching Contract 4 schema (event_type, tenant_id, severity, fingerprint_key, title, body, metadata). Wire the method into `handle()` event collection alongside existing `highDriftEvents()` and `compareFailedEvents()` at ~L64-L67.
- [X] T028 [P] [US3] Add `EVENT_PERMISSION_MISSING` option to `AlertRuleResource::eventTypeOptions()` in `app/Filament/Resources/AlertRuleResource.php` at ~L376: `AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing'`

### Tests for User Story 3

- [X] T029 [US3] Write alert integration tests in `tests/Feature/Alerts/PermissionMissingAlertTest.php`: (1) alert rule for EVENT_PERMISSION_MISSING with min severity=high + finding of severity=high → delivery queued, (2) alert rule with min severity=critical + finding of severity=high → no delivery queued, (3) same missing permission across two runs → cooldown/dedupe prevents duplicate notifications (fingerprint_key based suppression), (4) resolved findings do not produce alert events

**Checkpoint**: US3 complete — operators receive alerts for missing permissions via existing alert channels.

---

## Phase 6: Polish & Cross-Cutting Concerns

**Purpose**: Retention cleanup, configuration, end-to-end integration, and code quality.

- [X] T030 Create `PruneStoredReportsCommand` in `app/Console/Commands/PruneStoredReportsCommand.php` with signature `stored-reports:prune {--days=}`. Default days from `config('tenantpilot.stored_reports.retention_days', 90)`. Deletes `StoredReport::where('created_at', '<', now()->subDays($days))`. Outputs count of deleted records.
- [X] T031 Add `stored_reports.retention_days` configuration key to `config/tenantpilot.php` with default value 90. Register `stored-reports:prune` command in daily schedule in `routes/console.php`.
- [X] T032 [P] Write retention pruning tests in `tests/Feature/PermissionPosture/PruneStoredReportsCommandTest.php`: (1) reports older than threshold are deleted, (2) reports within threshold are preserved, (3) custom --days flag overrides config default
- [X] T033 Write end-to-end integration test in `tests/Feature/PermissionPosture/PermissionPostureIntegrationTest.php`: simulate full flow — health check calls compare() → posture job dispatched → findings created for missing permissions → stored report created with score → alert events produced for qualifying findings → OperationRun completed with correct counts. Include a lightweight timing assertion: posture generation for a 14-permission tenant completes in <5s (`expect($elapsed)->toBeLessThan(5000)`).
- [X] T034 Run `vendor/bin/sail bin pint --dirty` to fix formatting, then run `vendor/bin/sail artisan test --compact --filter=PermissionPosture` to verify all Spec 104 tests pass

---

## Dependencies & Execution Order

### Phase Dependencies

```
Phase 2 (Foundation) ─────┬──> Phase 3 (US1 - P1) 🎯 ──┬──> Phase 4 (US2+US4 - P2)
                           │                              ├──> Phase 5 (US3 - P3)
                           │                              └──> Phase 6 (Polish)
                           │
                           └── BLOCKS all user story work
```

- **Foundation (Phase 2)**: No dependencies — start immediately. BLOCKS all user stories.
- **US1 (Phase 3)**: Depends on Phase 2 completion. This is the MVP.
- **US2+US4 (Phase 4)**: Depends on Phase 3 (generator creates reports and scores).
- **US3 (Phase 5)**: Depends on Phase 3 (generator produces alert events).
- **US4 and US2 can run in parallel with US3** after Phase 3 completes.
- **Polish (Phase 6)**: Depends on all user stories being complete.

### User Story Dependencies

- **User Story 1 (P1)**: Can start after Foundation — No dependencies on other stories. **This is the MVP.**
- **User Story 4 (P2)**: Depends on US1 (PostureScoreCalculator built there). Tests only — no new implementation.
- **User Story 2 (P2)**: Depends on US1 (generator creates reports). Tests only — no new implementation.
- **User Story 3 (P3)**: Depends on US1 (generator produces event data). New implementation in EvaluateAlertsJob + AlertRuleResource.

### Within Each User Story

- Implementation tasks before test tasks (test tasks reference the implementation)
- Models/VOs before services
- Services before jobs
- Jobs before hooks/integration points

### Parallel Opportunities

**Phase 2 (Foundation)**:
- T001 + T002 (migrations, independent files)
- T003 + T004 + T005 (models, independent files)
- T007 + T008 + T009 + T010 (constants/badges, independent files)
- T012 + T013 + T014 (tests, independent files)

**Phase 3 (US1)**:
- T015 + T016 (PostureResult VO + PostureScoreCalculator, independent files)
- T020 can start after T016 (tests calculator)

**Phase 4 (US2+US4)**:
- T023 + T025 + T026 (independent test files)

**Phase 5 (US3)**:
- T028 can run in parallel with T027 (different files)

---

## Parallel Example: Foundation Phase

```bash
# Batch 1: Migrations (parallel)
T001: Create stored_reports migration
T002: Create findings resolved migration

# Batch 2: Models + Constants (parallel, after migrations written)
T003: StoredReport model
T004: StoredReportFactory
T005: Finding model extensions
T007: OperationCatalog constant
T008: AlertRule constant
T009: FindingStatusBadge resolved mapping
T010: FindingTypeBadge

# Batch 3: Factory states (after T005)
T006: FindingFactory states

# Batch 4: Run migrations
T011: vendor/bin/sail artisan migrate

# Batch 5: Tests (parallel)
T012: StoredReport CRUD test
T013: Finding resolved lifecycle test
T014: Badge rendering test
```

---

## Implementation Strategy

### MVP First (User Story 1 Only)

1. Complete Phase 2: Foundation (migrations, models, constants, badges)
2. Complete Phase 3: User Story 1 (generator, job, health check hook)
3. **STOP and VALIDATE**: Run `vendor/bin/sail artisan test --compact --filter=PermissionPosture`
4. Posture findings are now generated automatically after each health check

### Incremental Delivery

1. Foundation → ready
2. US1 (P1) → MVP: findings generated, auto-resolved, re-opened, tracked ✅
3. US2+US4 (P2) → stored reports + posture scores verified ✅
4. US3 (P3) → alerts for missing permissions ✅
5. Polish → retention, integration test, code quality ✅

Each phase adds value without breaking previous phases.

---

## Notes

- [P] tasks = different files, no dependencies on incomplete tasks
- [Story] label maps task to specific user story for traceability
- Fingerprint formula: `sha256("permission_posture:{tenant_id}:{permission_key}")` truncated to 64 chars
- Severity tiers from features count: 0→low, 1→medium, 2→high, 3+→critical
- All posture findings set `source='permission_check'` per FR-007
- Alert events produced only for new/reopened findings, NOT for resolved or unchanged
- Commit after each task or logical group
