# Tasks — Provider Access Hardening v1: Intune Write Gate

**Feature**: `108-provider-access-hardening`  
**Branch**: `108-provider-access-hardening`  
**Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Data Model**: [data-model.md](data-model.md)  
**Generated**: 2026-02-22

---

## Implementation Strategy

MVP = US1 + US2 + US3 (the three P1 safety gates). These can be verified end-to-end before any UX or audit work begins.

Delivery order:
1. Config + exception (Phase 1)
2. Gate service — the shared core (Phase 2 — blocks all phases)
3. Start-surface enforcement (Phase 3 — US1+US2)
4. Job-level enforcement (Phase 4 — US3)
5. UI disabled actions + RBAC card (Phase 5 — US4+US5)
6. Audit logging (Phase 6 — US6)
7. Polish (Phase 7)

---

## Phase 1: Setup

> **Pre-condition (B2)**: `App\Services\Intune\RbacHealthService` exists and writes `rbac_status`, `rbac_status_reason`, and `rbac_last_checked_at` on Tenant, but has **zero callers**. T001b below wires it into a dispatchable job. Without it the gate's `ok + fresh` path is never reachable.

- [X] T001 Add `hardening.intune_write_gate` config block to `config/tenantpilot.php` with keys: `enabled` (bool, default `true`) and `freshness_threshold_hours` (int, default `24`)
- [X] T001b Create `App\Jobs\RefreshTenantRbacHealthJob` in `app/Jobs/RefreshTenantRbacHealthJob.php` — a queued job that accepts a `Tenant $tenant` and calls `App\Services\Intune\RbacHealthService` to run the RBAC health check and persist the result; follow the existing `ProviderConnectionHealthCheckJob` pattern for locking + OperationRun observability; this job is dispatched by the new header action in T018; **also** add `case RbacHealthCheck = 'rbac.health_check'` to `app/Support/OperationRunType.php` — this enum is the canonical type registry and all OperationRun types must be registered there
- [X] T002 Create `App\Exceptions\Hardening\ProviderAccessHardeningRequired` exception in `app/Exceptions/Hardening/ProviderAccessHardeningRequired.php` carrying: `tenantId` (int), `operationType` (string), `reasonCode` (string, one of `intune_rbac.not_configured` | `intune_rbac.unhealthy` | `intune_rbac.stale`), and `reasonMessage` (string)

---

## Phase 2: Foundational

> Must complete before any user story phase.

- [X] T002b Create `App\Contracts\Hardening\WriteGateInterface` in `app/Contracts/Hardening/WriteGateInterface.php` with two methods: `evaluate(Tenant $tenant, string $operationType): void` (throws `ProviderAccessHardeningRequired` when blocked) and `wouldBlock(Tenant $tenant): bool` (non-throwing, for UI disabled-state checks); `IntuneRbacWriteGate` (T003) implements both; satisfies FR-011 (provider-agnostic design) and makes future gate implementations pluggable without code changes in start surfaces or jobs
- [X] T003 Create `App\Services\Hardening\IntuneRbacWriteGate` in `app/Services/Hardening/IntuneRbacWriteGate.php` implementing `WriteGateInterface` with:
  - `evaluate(Tenant $tenant, string $operationType): void`: returns when gate is disabled (logs warning); throws `ProviderAccessHardeningRequired` with `intune_rbac.not_configured` when `rbac_status` is `null` or `not_configured`; throws `intune_rbac.unhealthy` when `rbac_status` is `degraded` or `failed`; throws `intune_rbac.stale` when `rbac_status = ok` but `rbac_last_checked_at` is older than the freshness threshold; passes through only when `rbac_status = ok` AND timestamp is fresh
  - `wouldBlock(Tenant $tenant): bool`: calls the same evaluation logic, catches `ProviderAccessHardeningRequired`, and returns `true`/`false` without throwing — used by UI disabled-state closures (T016)

---

## Phase 3: Start-Surface Enforcement [US1+US2]

**Story goal**: All Intune write start surfaces block before enqueuing any job or creating a write OperationRun.

**Independent test criteria**: Attempt a restore start on a tenant with `rbac_status = null`, `degraded`, or stale timestamp → operation is blocked, no jobs dispatched.

- [X] T004 [P] [US1+US2] Enforce gate in `RestoreRunResource::createRestoreRun()` in `app/Filament/Resources/RestoreRunResource.php` — call `IntuneRbacWriteGate::evaluate()` after authorization checks and before any `RestoreRun::create()` / `OperationRun` / `ExecuteRestoreRunJob::dispatch()` call; catch `ProviderAccessHardeningRequired` and throw a Filament notification + validation exception with the reason message
- [X] T005 [P] [US1+US2] Enforce gate in the rerun action path inside `RestoreRunResource.php` (around L860–L980) — same pattern: call gate before `ExecuteRestoreRunJob::dispatch($newRun->id, ...)`; present reason to operator via `Notification::make()->danger()` and return early
- [X] T006 [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGateNotConfiguredTest.php`: assert that calling the restore start path with `rbac_status = null` and `rbac_status = not_configured` does NOT enqueue `ExecuteRestoreRunJob` and returns reason code `intune_rbac.not_configured`
- [X] T007 [P] [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGateUnhealthyTest.php`: assert that `rbac_status = degraded` and `rbac_status = failed` block with `intune_rbac.unhealthy`; mock `ExecuteRestoreRunJob` and assert zero invocations
- [X] T008 [P] [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGateStaleTest.php`: assert that `rbac_status = ok` with `rbac_last_checked_at` older than configured threshold blocks with `intune_rbac.stale`
- [X] T009 [US1+US2] Write Pest feature test `tests/Feature/Hardening/RestoreStartGatePassesTest.php`: assert that `rbac_status = ok` with fresh `rbac_last_checked_at` allows the operation to proceed (job is dispatched)
- [X] T010 [US1+US2] Write Pest test asserting gate bypass: when `tenantpilot.hardening.intune_write_gate.enabled = false`, restore start proceeds and a warning is logged (assert `Log::warning` called with bypass message)

---

## Phase 4: Job-Level Gate [US3]

**Story goal**: Even if a write job is enqueued by any path, the job re-checks the gate before touching the Graph and marks the OperationRun failed with a stable reason code.

**Independent test criteria**: Instantiate a restore job with a tenant in blocked state → OperationRun is marked failed with reason code; zero Graph calls made.

- [X] T011 [P] [US3] Enforce gate in `ExecuteRestoreRunJob::handle()` in `app/Jobs/ExecuteRestoreRunJob.php` — call `IntuneRbacWriteGate::evaluate()` after loading `$restoreRun` and `$tenant`, before calling `$restoreService->executeForRun(...)`; on `ProviderAccessHardeningRequired`: update `RestoreRun` status to `Failed` with sanitized `failure_reason`; update the associated `OperationRun` via `OperationRunService` with `outcome = failed` and `failures[reason_code]` set to the stable reason code; do NOT rethrow the exception
- [X] T012 [P] [US3] Enforce gate in `RestoreAssignmentsJob::handle()` in `app/Jobs/RestoreAssignmentsJob.php` — call gate after resolving `$tenant` and `$run`, before calling `$assignmentRestoreService->restore(...)`; on `ProviderAccessHardeningRequired`: call `OperationRunService::updateRun()` with `outcome = failed`, `failures[reason_code]` = the stable reason code, `summaryCounts total = max(1, count($this->assignments))`; return early without Graph calls
- [X] T013 [P] [US3] Write Pest feature test `tests/Feature/Hardening/ExecuteRestoreRunJobGateTest.php`: dispatch `ExecuteRestoreRunJob` with a tenant with `rbac_status = not_configured` → assert OperationRun is marked failed with `reason_code = intune_rbac.not_configured` in failures; mock `RestoreService` and assert `executeForRun` is never called
- [X] T014 [P] [US3] Write Pest feature test `tests/Feature/Hardening/RestoreAssignmentsJobGateTest.php`: dispatch `RestoreAssignmentsJob` via `dispatchTracked()` with a tenant with stale `rbac_last_checked_at` → assert OperationRun is marked failed with `reason_code = intune_rbac.stale`; mock `AssignmentRestoreService` and assert `restore` is never called
- [X] T015 [US3] Write Pest test asserting zero Graph write calls: for both jobs in blocked state, assert `GraphClientInterface` (mock) receives zero POST/PATCH/PUT invocations
- [X] T015b [US3 / SC-005] Write Pest unit test `tests/Unit/Hardening/IntuneRbacWriteGateNoHttpTest.php`: call `IntuneRbacWriteGate::evaluate()` with all three blocked states using a real in-process Tenant model; wrap with `Http::fake()` and assert `Http::assertNothingSent()` — verifies gate evaluation never triggers an outbound HTTP call (constitution DB-only requirement)

---

## Phase 5: UI Affordance [US4+US5]

**Story goal**: Write actions are visibly disabled with reason + CTA before operators attempt them; Tenant view RBAC section shows a compact status card.

**Independent test criteria**: Render restorerun view action with blocked tenant → assert action `isDisabled()` and helper text contains the reason; render tenant view with various `rbac_status` → assert correct badge and action labels.

- [X] T016 [P] [US4] Disable write-trigger Filament actions in `RestoreRunResource.php` — for the execute/rerun action(s), add `->disabled(fn ($record): bool => app(WriteGateInterface::class)->wouldBlock($record->tenant))` + `->tooltip(fn ($record): ?string => ...)` returning the human-readable reason or null; `RestoreRun` has a direct `tenant()` BelongsTo — use `$record->tenant`, **not** `$record->restoreBackup->tenant` or any other path; action must remain visible but clearly unavailable when gate would block; **do not** capture `$tenant` via `use ()` — use the `$record` closure argument injected by Filament
- [X] T017 [P] [US5] Replace the flat RBAC field list in `TenantResource::infolist()` (`app/Filament/Resources/TenantResource.php`, the `Section::make('RBAC')` block at ~L862) with a compact status card using a `ViewEntry` or structured `TextEntry` rows that shows: `TenantRbacStatus` badge, short explanation sentence, and contextual infolist actions ("Setup Intune RBAC" → `TenantResource::rbacAction()` modal; "Run health check" → existing verify action surf); retain collapsible raw detail fields behind a secondary "View details" affordance
- [X] T018 [P] [US5] Add "Refresh RBAC status" header action to `ViewTenant::getHeaderActions()` in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` — dispatch `RefreshTenantRbacHealthJob` (T001b) from the action closure; **do not** reuse `StartVerification` (that checks provider connections, not RBAC health); follow the existing `verify` action pattern for locking, deduplication, and `scope_busy` / `started` / `blocked` notification variants; use `UiEnforcement::forAction(...)` wrapper consistent with existing header actions
- [X] T019 [US4] Write Pest Livewire test `tests/Feature/Hardening/RestoreRunActionDisabledStateTest.php`: mount the RestoreRun page as a Livewire component, assert the execute action is disabled when `rbac_status = null` and the tooltip helper contains the expected reason text; assert action is enabled when `rbac_status = ok` with fresh health
- [X] T020 [US5] Write Pest Livewire test `tests/Feature/Hardening/TenantRbacCardRenderTest.php`: assert the RBAC card renders the correct `TenantRbacStatus` badge and CTA label for each status value: `null` → "Not Configured" + "Setup Intune RBAC"; `ok` → "Healthy" + "Run health check"; `degraded` → "Degraded" + "Run health check" + "View details" affordance is present and collapsible (covering US5 acceptance scenario 3)

---

## Phase 6: Audit Logging [US6]

**Story goal**: Blocked write attempts at the UI start surface are recorded in `AuditLog` for compliance and post-incident review (OperationRun failures already provide job-level audit).

**Independent test criteria**: Trigger a blocked write via the start surface → assert an `AuditLog` record exists with `action = intune_rbac.write_blocked`, the correct `tenant_id`, and operation type in metadata.

- [X] T021 [US6] Write `AuditLog` entry in the `ProviderAccessHardeningRequired` catch block in `RestoreRunResource::createRestoreRun()` (and rerun path) using `App\Services\Intune\AuditLogger::log()` with: `action = 'intune_rbac.write_blocked'`, `status = 'blocked'` (`audit_logs.status` is a plain unconstrained `string` column — no enum restriction), `context.metadata` containing `operation_type`, `reason_code`, and the restore run / backup set IDs (no secrets, no raw payloads)
- [X] T022 [US6] Write Pest feature test `tests/Feature/Hardening/BlockedWriteAuditLogTest.php`: trigger a blocked restore start → assert an `AuditLog` record exists with the expected `action`, `tenant_id`, and sanitized metadata; assert it does NOT contain any token or credential fields

---

## Phase 7: Polish & Cross-cutting

- [X] T023 Register missing status values in `app/Support/Badges/Domains/TenantRbacStatusBadge.php`: add `stale` (amber warning, `heroicon-m-clock`) and `degraded` (amber warning, `heroicon-m-exclamation-triangle`) — both values are currently absent from the `match` block; update `BadgeCatalog` if new values require catalog registration; BADGE-001 requires all status-like values to map to non-ad-hoc label + color + icon
- [X] T024 Write Pest unit test `tests/Unit/Badge/TenantRbacStatusBadgeTest.php`: assert all badge values (`null`, `not_configured`, `ok`, `degraded`, `failed`, `stale`) map to non-empty label + color; no ad-hoc status mapping
> **Note (T025)**: Toggle regression is covered by T010 (gate bypass Pest test with `Log::warning` assertion). No separate verification script needed.
- [X] T026 Run `vendor/bin/sail bin pint --dirty` and commit any formatting corrections before finalizing the feature branch
- [X] T027 Create `specs/108-provider-access-hardening/checklists/requirements.md` using the standard checklist template — constitution v1.9.0 Spec-First Workflow requires this file for features that change runtime behavior

---

## Dependencies (story completion order)

```
Phase 1 (T001, T001b, T002)
  → Phase 2 (T002b, T003)     [T002b before T003; T003 blocks all writes]
    → Phase 3 (US1+US2)      [T004, T005 after T003]
    → Phase 4 (US3)          [T011, T012, T013–T015b in parallel with Phase 3]
    → Phase 5 (US4+US5)      [T016 needs T002b for WriteGateInterface;
                               T018 needs T001b for RefreshTenantRbacHealthJob]
    → Phase 6 (US6)          [T021, T022 after Phase 3]
Phase 7 (T023, T024, T026, T027)  [T023 any time after Phase 2; T027 any time]
```

MVP cutoff: **T001–T015b** (Phases 1–4) delivers all three P1 safety gates; US4+US5+US6 (Phases 5–6) can follow.

---

## Parallel Execution (per story)

| Story | Parallelizable tasks |
|---|---|
| Phase 1 | T001 \u2016 T001b simultaneously |
| US1+US2 | T004 \u2016 T005 simultaneously; then T006 \u2016 T007 \u2016 T008 \u2016 T009 simultaneously |
| US3 | T011 \u2016 T012 simultaneously; then T013 \u2016 T014 \u2016 T015 \u2016 T015b simultaneously |
| US4+US5 | T016 \u2016 T017 \u2016 T018 simultaneously; then T019 \u2016 T020 simultaneously |
| US6 | T021 then T022 |
| Polish | T023 \u2016 T024 simultaneously; T026 last; T027 any time |
