- T001-T014: Foundation - StoredReport model/migration, Finding resolved lifecycle, badge mappings (resolved status, permission_posture type), OperationCatalog + AlertRule constants - T015-T022: US1 - PermissionPostureFindingGenerator with fingerprint-based idempotent upsert, severity from feature-impact count, auto-resolve on grant, auto-reopen on revoke, error findings (FR-015), stale finding cleanup; GeneratePermissionPostureFindingsJob dispatched from health check; PostureResult VO + PostureScoreCalculator - T023-T026: US2+US4 - Stored report payload validation, temporal ordering, polymorphic reusability, score accuracy acceptance tests - T027-T029: US3 - EvaluateAlertsJob.permissionMissingEvents() wired into alert pipeline, AlertRuleResource event type option, cooldown/dedupe tests - T030-T034: Polish - PruneStoredReportsCommand with config retention, scheduled daily, end-to-end integration test, Pint clean UI bug fixes found during testing: - FindingResource: hide Diff section for non-drift findings - TenantRequiredPermissions: fix re-run verification link - tenant-required-permissions.blade.php: preserve details open state 70 tests (50 PermissionPosture + 20 FindingResolved/Badge/Alert), 216 assertions
22 KiB
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
- T001 Create migration for
stored_reportstable viavendor/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. Seespecs/104-provider-permission-posture/data-model.mdfor full schema. - T002 [P] Create migration to add
resolved_at(timestampTz, nullable) andresolved_reason(string(255), nullable) tofindingstable viavendor/bin/sail artisan make:migration add_resolved_to_findings_table. These are global columns usable by all finding types per spec clarification.
Models & Factories
- T003 [P] Create
StoredReportmodel inapp/Models/StoredReport.phpwith:REPORT_TYPE_PERMISSION_POSTUREconstant,DerivesWorkspaceIdFromTenant+HasFactorytraits, fillable[workspace_id, tenant_id, report_type, payload],casts()method withpayload → array,workspace()BelongsTo andtenant()BelongsTo relationships. Seespecs/104-provider-permission-posture/data-model.mdStoredReport model section. - T004 [P] Create
StoredReportFactoryindatabase/factories/StoredReportFactory.phpwith default state:tenant_id → Tenant::factory(),report_type → StoredReport::REPORT_TYPE_PERMISSION_POSTURE,payloadcontaining sample posture data (posture_score, required_count, granted_count, checked_at, permissions array). Seespecs/104-provider-permission-posture/data-model.mdpayload schema. - T005 Extend
Findingmodel inapp/Models/Finding.php: addSTATUS_RESOLVED = 'resolved'constant,FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture'constant (error findings use the same type, distinguished bycheck_error=truein evidence per FR-015 — no separate constant needed),resolved_atdatetime cast incasts()method,resolve(string $reason): voidmethod (sets status, resolved_at, resolved_reason, saves),reopen(array $evidence): voidmethod (sets status=new, clears resolved_at/resolved_reason, updates evidence_jsonb, saves). Seespecs/104-provider-permission-posture/data-model.mdFinding model section. - T006 Add
permissionPosture()andresolved()factory states todatabase/factories/FindingFactory.php.permissionPosture()sets:finding_type → Finding::FINDING_TYPE_PERMISSION_POSTURE,source → 'permission_check',severity → Finding::SEVERITY_MEDIUM, sample permission evidence inevidence_jsonb.resolved()sets:status → Finding::STATUS_RESOLVED,resolved_at → now(),resolved_reason → 'permission_granted'.
Constants & Badge Mappings
- T007 [P] Add
TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check'constant toapp/Support/OperationCatalog.php - T008 [P] Add
EVENT_PERMISSION_MISSING = 'permission_missing'constant toapp/Models/AlertRule.php - T009 [P] Add
resolvedstatus badge mapping (color:success, icon:heroicon-o-check-circle) toapp/Support/Badges/Domains/FindingStatusBadge.php - T010 [P] Create
FindingTypeBadgeinapp/Support/Badges/Domains/FindingTypeBadge.phpfollowing the pattern ofFindingStatusBadge.php/FindingSeverityBadge.php. Mapdrift → info,permission_posture → warning. Register in badge catalog per BADGE-001.
Verify & Test Foundation
- T011 Run migrations via
vendor/bin/sail artisan migrateand verifystored_reportstable andfindingscolumn additions exist - 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 - 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 fromacknowledgedpreserves acknowledged_at/acknowledged_by - T014 [P] Write badge rendering tests for
resolvedstatus andpermission_posturefinding type intests/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
- T015 [P] [US1] Create
PostureResultvalue object inapp/Services/PermissionPosture/PostureResult.phpwith readonly properties:findingsCreated,findingsResolved,findingsReopened,findingsUnchanged,errorsRecorded,postureScore,storedReportId. Seespecs/104-provider-permission-posture/contracts/internal-services.mdContract 1 output. - T016 [P] [US1] Create
PostureScoreCalculatorservice inapp/Services/PermissionPosture/PostureScoreCalculator.phpwithcalculate(array $permissionComparison): int— returnsround(granted / required * 100), returns 100 when required_count is 0. Pure function, no DB access. Seespecs/104-provider-permission-posture/contracts/internal-services.mdContract 2. - T017 [US1] Create
PermissionPostureFindingGeneratorservice inapp/Services/PermissionPosture/PermissionPostureFindingGenerator.phpwithgenerate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult. Implementation must: (1) iterate permissions from comparison, (2) forstatus=missing: create/reopen findings via fingerprint upsert (firstOrNewon[tenant_id, fingerprint]), (3) forstatus=granted: auto-resolve existing open findings, (4) forstatus=error: create error findings withcheck_error=truein evidence and distinct fingerprintsha256("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 tolow), (6) setsubject_type='permission'andsubject_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 openpermission_posturefindings for this tenant whosepermission_key(from evidence) is NOT present in the current comparison — this handles registry removals per edge case "Registry changes". Seespecs/104-provider-permission-posture/contracts/internal-services.mdContract 1 andspecs/104-provider-permission-posture/data-model.md. - T018 [US1] Create
GeneratePermissionPostureFindingsJobinapp/Jobs/GeneratePermissionPostureFindingsJob.phpwith constructor acceptingint $tenantIdandarray $permissionComparison.handle()must: (1) load Tenant or fail, (2) skip if no active provider connection (FR-016), (3) create OperationRun withtype=permission_posture_check, (4) callPermissionPostureFindingGenerator::generate(), (5) record summary counts on OperationRun, (6) complete OperationRun. Seespecs/104-provider-permission-posture/contracts/internal-services.mdContract 3. - T019 [US1] Modify
app/Jobs/ProviderConnectionHealthCheckJob.phpto dispatchGeneratePermissionPostureFindingsJobafterTenantPermissionService::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
- 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 - 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 withcheck_error=truein 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 fromconfig('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') - 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
- T023 [P] [US4] Extend PostureScoreCalculator tests in
tests/Feature/PermissionPosture/PostureScoreCalculatorTest.phpwith 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
- 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 - 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 - 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
- T027 [US3] Add
permissionMissingEvents(): arraymethod toapp/Jobs/Alerts/EvaluateAlertsJob.php— queriesFindingwherefinding_type='permission_posture',status IN ('new'), within the time window (filter byupdated_at, notcreated_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 intohandle()event collection alongside existinghighDriftEvents()andcompareFailedEvents()at ~L64-L67. - T028 [P] [US3] Add
EVENT_PERMISSION_MISSINGoption toAlertRuleResource::eventTypeOptions()inapp/Filament/Resources/AlertRuleResource.phpat ~L376:AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing'
Tests for User Story 3
- 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.
- T030 Create
PruneStoredReportsCommandinapp/Console/Commands/PruneStoredReportsCommand.phpwith signaturestored-reports:prune {--days=}. Default days fromconfig('tenantpilot.stored_reports.retention_days', 90). DeletesStoredReport::where('created_at', '<', now()->subDays($days)). Outputs count of deleted records. - T031 Add
stored_reports.retention_daysconfiguration key toconfig/tenantpilot.phpwith default value 90. Registerstored-reports:prunecommand in daily schedule inroutes/console.php. - 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 - 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)). - T034 Run
vendor/bin/sail bin pint --dirtyto fix formatting, then runvendor/bin/sail artisan test --compact --filter=PermissionPostureto 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
# 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)
- Complete Phase 2: Foundation (migrations, models, constants, badges)
- Complete Phase 3: User Story 1 (generator, job, health check hook)
- STOP and VALIDATE: Run
vendor/bin/sail artisan test --compact --filter=PermissionPosture - Posture findings are now generated automatically after each health check
Incremental Delivery
- Foundation → ready
- US1 (P1) → MVP: findings generated, auto-resolved, re-opened, tracked ✅
- US2+US4 (P2) → stored reports + posture scores verified ✅
- US3 (P3) → alerts for missing permissions ✅
- 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