TenantAtlas/specs/108-provider-access-hardening/tasks.md
Ahmed Darrazi 12973248e7 feat: provider access hardening (RBAC write gate)
Implements RBAC-based write gating for Intune restore flows, UI affordances, and audit logging; adds tests and specs.
2026-02-23 01:20:28 +01:00

139 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 L860L980) — 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, T013T015b 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: **T001T015b** (Phases 14) delivers all three P1 safety gates; US4+US5+US6 (Phases 56) 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 |