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

15 KiB
Raw Blame History

Tasks — Provider Access Hardening v1: Intune Write Gate

Feature: 108-provider-access-hardening
Branch: 108-provider-access-hardening
Spec: spec.md | Plan: plan.md | Data Model: 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.

  • 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)
  • 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
  • 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.

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

  • 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
  • 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
  • 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
  • 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
  • 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
  • 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)
  • 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.

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

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

  • 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)
  • 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

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

  • T026 Run vendor/bin/sail bin pint --dirty and commit any formatting corrections before finalizing the feature branch
  • 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