Implements provider access hardening for Intune write operations: - RBAC-based write gate with configurable staleness thresholds - Gate enforced at restore start and in jobs (execute + assignments) - UI affordances: disabled rerun action, tenant RBAC status card, refresh RBAC action - Audit logging for blocked writes - Ops UX label: `rbac.health_check` now displays as “RBAC health check” - Adds/updates Pest tests and SpecKit artifacts for feature 108 Notes: - Filament v5 / Livewire v4 compliant. - Destructive actions require confirmation. - Assets: no new global assets. Tested: - `vendor/bin/sail artisan test --compact` (suite previously green) + focused OpsUx tests for OperationCatalog labels. - `vendor/bin/sail bin pint --dirty`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #132
15 KiB
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:
- Config + exception (Phase 1)
- Gate service — the shared core (Phase 2 — blocks all phases)
- Start-surface enforcement (Phase 3 — US1+US2)
- Job-level enforcement (Phase 4 — US3)
- UI disabled actions + RBAC card (Phase 5 — US4+US5)
- Audit logging (Phase 6 — US6)
- Polish (Phase 7)
Phase 1: Setup
Pre-condition (B2):
App\Services\Intune\RbacHealthServiceexists and writesrbac_status,rbac_status_reason, andrbac_last_checked_aton Tenant, but has zero callers. T001b below wires it into a dispatchable job. Without it the gate'sok + freshpath is never reachable.
- T001 Add
hardening.intune_write_gateconfig block toconfig/tenantpilot.phpwith keys:enabled(bool, defaulttrue) andfreshness_threshold_hours(int, default24) - T001b Create
App\Jobs\RefreshTenantRbacHealthJobinapp/Jobs/RefreshTenantRbacHealthJob.php— a queued job that accepts aTenant $tenantand callsApp\Services\Intune\RbacHealthServiceto run the RBAC health check and persist the result; follow the existingProviderConnectionHealthCheckJobpattern for locking + OperationRun observability; this job is dispatched by the new header action in T018; also addcase RbacHealthCheck = 'rbac.health_check'toapp/Support/OperationRunType.php— this enum is the canonical type registry and all OperationRun types must be registered there - T002 Create
App\Exceptions\Hardening\ProviderAccessHardeningRequiredexception inapp/Exceptions/Hardening/ProviderAccessHardeningRequired.phpcarrying:tenantId(int),operationType(string),reasonCode(string, one ofintune_rbac.not_configured|intune_rbac.unhealthy|intune_rbac.stale), andreasonMessage(string)
Phase 2: Foundational
Must complete before any user story phase.
- T002b Create
App\Contracts\Hardening\WriteGateInterfaceinapp/Contracts/Hardening/WriteGateInterface.phpwith two methods:evaluate(Tenant $tenant, string $operationType): void(throwsProviderAccessHardeningRequiredwhen blocked) andwouldBlock(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\IntuneRbacWriteGateinapp/Services/Hardening/IntuneRbacWriteGate.phpimplementingWriteGateInterfacewith:evaluate(Tenant $tenant, string $operationType): void: returns when gate is disabled (logs warning); throwsProviderAccessHardeningRequiredwithintune_rbac.not_configuredwhenrbac_statusisnullornot_configured; throwsintune_rbac.unhealthywhenrbac_statusisdegradedorfailed; throwsintune_rbac.stalewhenrbac_status = okbutrbac_last_checked_atis older than the freshness threshold; passes through only whenrbac_status = okAND timestamp is freshwouldBlock(Tenant $tenant): bool: calls the same evaluation logic, catchesProviderAccessHardeningRequired, and returnstrue/falsewithout 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()inapp/Filament/Resources/RestoreRunResource.php— callIntuneRbacWriteGate::evaluate()after authorization checks and before anyRestoreRun::create()/OperationRun/ExecuteRestoreRunJob::dispatch()call; catchProviderAccessHardeningRequiredand 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 L860–L980) — same pattern: call gate beforeExecuteRestoreRunJob::dispatch($newRun->id, ...); present reason to operator viaNotification::make()->danger()and return early - T006 [US1+US2] Write Pest feature test
tests/Feature/Hardening/RestoreStartGateNotConfiguredTest.php: assert that calling the restore start path withrbac_status = nullandrbac_status = not_configureddoes NOT enqueueExecuteRestoreRunJoband returns reason codeintune_rbac.not_configured - T007 [P] [US1+US2] Write Pest feature test
tests/Feature/Hardening/RestoreStartGateUnhealthyTest.php: assert thatrbac_status = degradedandrbac_status = failedblock withintune_rbac.unhealthy; mockExecuteRestoreRunJoband assert zero invocations - T008 [P] [US1+US2] Write Pest feature test
tests/Feature/Hardening/RestoreStartGateStaleTest.php: assert thatrbac_status = okwithrbac_last_checked_atolder than configured threshold blocks withintune_rbac.stale - T009 [US1+US2] Write Pest feature test
tests/Feature/Hardening/RestoreStartGatePassesTest.php: assert thatrbac_status = okwith freshrbac_last_checked_atallows 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 (assertLog::warningcalled 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()inapp/Jobs/ExecuteRestoreRunJob.php— callIntuneRbacWriteGate::evaluate()after loading$restoreRunand$tenant, before calling$restoreService->executeForRun(...); onProviderAccessHardeningRequired: updateRestoreRunstatus toFailedwith sanitizedfailure_reason; update the associatedOperationRunviaOperationRunServicewithoutcome = failedandfailures[reason_code]set to the stable reason code; do NOT rethrow the exception - T012 [P] [US3] Enforce gate in
RestoreAssignmentsJob::handle()inapp/Jobs/RestoreAssignmentsJob.php— call gate after resolving$tenantand$run, before calling$assignmentRestoreService->restore(...); onProviderAccessHardeningRequired: callOperationRunService::updateRun()withoutcome = 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: dispatchExecuteRestoreRunJobwith a tenant withrbac_status = not_configured→ assert OperationRun is marked failed withreason_code = intune_rbac.not_configuredin failures; mockRestoreServiceand assertexecuteForRunis never called - T014 [P] [US3] Write Pest feature test
tests/Feature/Hardening/RestoreAssignmentsJobGateTest.php: dispatchRestoreAssignmentsJobviadispatchTracked()with a tenant with stalerbac_last_checked_at→ assert OperationRun is marked failed withreason_code = intune_rbac.stale; mockAssignmentRestoreServiceand assertrestoreis 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: callIntuneRbacWriteGate::evaluate()with all three blocked states using a real in-process Tenant model; wrap withHttp::fake()and assertHttp::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;RestoreRunhas a directtenant()BelongsTo — use$record->tenant, not$record->restoreBackup->tenantor any other path; action must remain visible but clearly unavailable when gate would block; do not capture$tenantviause ()— use the$recordclosure argument injected by Filament - T017 [P] [US5] Replace the flat RBAC field list in
TenantResource::infolist()(app/Filament/Resources/TenantResource.php, theSection::make('RBAC')block at ~L862) with a compact status card using aViewEntryor structuredTextEntryrows that shows:TenantRbacStatusbadge, 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()inapp/Filament/Resources/TenantResource/Pages/ViewTenant.php— dispatchRefreshTenantRbacHealthJob(T001b) from the action closure; do not reuseStartVerification(that checks provider connections, not RBAC health); follow the existingverifyaction pattern for locking, deduplication, andscope_busy/started/blockednotification variants; useUiEnforcement::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 whenrbac_status = nulland the tooltip helper contains the expected reason text; assert action is enabled whenrbac_status = okwith fresh health - T020 [US5] Write Pest Livewire test
tests/Feature/Hardening/TenantRbacCardRenderTest.php: assert the RBAC card renders the correctTenantRbacStatusbadge 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
AuditLogentry in theProviderAccessHardeningRequiredcatch block inRestoreRunResource::createRestoreRun()(and rerun path) usingApp\Services\Intune\AuditLogger::log()with:action = 'intune_rbac.write_blocked',status = 'blocked'(audit_logs.statusis a plain unconstrainedstringcolumn — no enum restriction),context.metadatacontainingoperation_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 anAuditLogrecord exists with the expectedaction,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: addstale(amber warning,heroicon-m-clock) anddegraded(amber warning,heroicon-m-exclamation-triangle) — both values are currently absent from thematchblock; updateBadgeCatalogif 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::warningassertion). No separate verification script needed.
- T026 Run
vendor/bin/sail bin pint --dirtyand commit any formatting corrections before finalizing the feature branch - T027 Create
specs/108-provider-access-hardening/checklists/requirements.mdusing 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 |