TenantAtlas/specs/104-provider-permission-posture/tasks.md
ahmido ef380b67d1 feat(104): Provider Permission Posture (#127)
Implements Spec 104: Provider Permission Posture.

What changed
- Generates permission posture findings after each tenant permission compare (queued)
- Stores immutable posture snapshots as StoredReports (JSONB payload)
- Adds global Finding resolved lifecycle (`resolved_at`, `resolved_reason`) with `resolve()` / `reopen()`
- Adds alert pipeline event type `permission_missing` (Alerts v1) and Filament option for Alert Rules
- Adds retention pruning command + daily schedule for StoredReports
- Adds badge mappings for `resolved` finding status and `permission_posture` finding type

UX fixes discovered during manual verification
- Hide “Diff” section for non-drift findings (only drift findings show diff)
- Required Permissions page: “Re-run verification” now links to Tenant view (not onboarding)
- Preserve Technical Details `<details>` open state across Livewire re-renders (Alpine state)

Verification
- Ran `vendor/bin/sail artisan test --compact --filter=PermissionPosture` (50 tests)
- Ran `vendor/bin/sail artisan test --compact --filter="FindingResolved|FindingBadge|PermissionMissingAlert"` (20 tests)
- Ran `vendor/bin/sail bin pint --dirty`

Filament v5 / Livewire v4 compliance
- Filament v5 + Livewire v4: no Livewire v3 usage.

Panel provider registration (Laravel 11+)
- No new panels added. Existing panel providers remain registered via `bootstrap/providers.php`.

Global search rule
- No changes to global-searchable resources.

Destructive actions
- No new destructive Filament actions were added in this PR.

Assets / deploy notes
- No new Filament assets registered. Existing deploy step `php artisan filament:assets` remains unchanged.

Test coverage
- New/updated Pest feature tests cover generator behavior, job integration, alerting, retention pruning, and resolved lifecycle.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #127
2026-02-21 22:32:52 +00:00

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_reports table via vendor/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. See specs/104-provider-permission-posture/data-model.md for full schema.
  • T002 [P] Create migration to add resolved_at (timestampTz, nullable) and resolved_reason (string(255), nullable) to findings table via vendor/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 StoredReport model in app/Models/StoredReport.php with: REPORT_TYPE_PERMISSION_POSTURE constant, DerivesWorkspaceIdFromTenant + HasFactory traits, fillable [workspace_id, tenant_id, report_type, payload], casts() method with payload → array, workspace() BelongsTo and tenant() BelongsTo relationships. See specs/104-provider-permission-posture/data-model.md StoredReport model section.
  • T004 [P] Create StoredReportFactory in database/factories/StoredReportFactory.php with default state: tenant_id → Tenant::factory(), report_type → StoredReport::REPORT_TYPE_PERMISSION_POSTURE, payload containing sample posture data (posture_score, required_count, granted_count, checked_at, permissions array). See specs/104-provider-permission-posture/data-model.md payload schema.
  • T005 Extend Finding model in app/Models/Finding.php: add STATUS_RESOLVED = 'resolved' constant, FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture' constant (error findings use the same type, distinguished by check_error=true in evidence per FR-015 — no separate constant needed), resolved_at datetime cast in casts() method, resolve(string $reason): void method (sets status, resolved_at, resolved_reason, saves), reopen(array $evidence): void method (sets status=new, clears resolved_at/resolved_reason, updates evidence_jsonb, saves). See specs/104-provider-permission-posture/data-model.md Finding model section.
  • T006 Add permissionPosture() and resolved() factory states to database/factories/FindingFactory.php. permissionPosture() sets: finding_type → Finding::FINDING_TYPE_PERMISSION_POSTURE, source → 'permission_check', severity → Finding::SEVERITY_MEDIUM, sample permission evidence in evidence_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 to app/Support/OperationCatalog.php
  • T008 [P] Add EVENT_PERMISSION_MISSING = 'permission_missing' constant to app/Models/AlertRule.php
  • T009 [P] Add resolved status badge mapping (color: success, icon: heroicon-o-check-circle) to app/Support/Badges/Domains/FindingStatusBadge.php
  • T010 [P] Create FindingTypeBadge in app/Support/Badges/Domains/FindingTypeBadge.php following the pattern of FindingStatusBadge.php / FindingSeverityBadge.php. Map drift → info, permission_posture → warning. Register in badge catalog per BADGE-001.

Verify & Test Foundation

  • T011 Run migrations via vendor/bin/sail artisan migrate and verify stored_reports table and findings column 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 from acknowledged preserves acknowledged_at/acknowledged_by
  • T014 [P] Write badge rendering tests for resolved status and permission_posture finding type in tests/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 PostureResult value object in app/Services/PermissionPosture/PostureResult.php with readonly properties: findingsCreated, findingsResolved, findingsReopened, findingsUnchanged, errorsRecorded, postureScore, storedReportId. See specs/104-provider-permission-posture/contracts/internal-services.md Contract 1 output.
  • T016 [P] [US1] Create PostureScoreCalculator service in app/Services/PermissionPosture/PostureScoreCalculator.php with calculate(array $permissionComparison): int — returns round(granted / required * 100), returns 100 when required_count is 0. Pure function, no DB access. See specs/104-provider-permission-posture/contracts/internal-services.md Contract 2.
  • T017 [US1] Create PermissionPostureFindingGenerator service in app/Services/PermissionPosture/PermissionPostureFindingGenerator.php with generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult. Implementation must: (1) iterate permissions from comparison, (2) for status=missing: create/reopen findings via fingerprint upsert (firstOrNew on [tenant_id, fingerprint]), (3) for status=granted: auto-resolve existing open findings, (4) for status=error: create error findings with check_error=true in evidence and distinct fingerprint sha256("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 to low), (6) set subject_type='permission' and subject_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 open permission_posture findings for this tenant whose permission_key (from evidence) is NOT present in the current comparison — this handles registry removals per edge case "Registry changes". See specs/104-provider-permission-posture/contracts/internal-services.md Contract 1 and specs/104-provider-permission-posture/data-model.md.
  • T018 [US1] Create GeneratePermissionPostureFindingsJob in app/Jobs/GeneratePermissionPostureFindingsJob.php with constructor accepting int $tenantId and array $permissionComparison. handle() must: (1) load Tenant or fail, (2) skip if no active provider connection (FR-016), (3) create OperationRun with type=permission_posture_check, (4) call PermissionPostureFindingGenerator::generate(), (5) record summary counts on OperationRun, (6) complete OperationRun. See specs/104-provider-permission-posture/contracts/internal-services.md Contract 3.
  • T019 [US1] Modify app/Jobs/ProviderConnectionHealthCheckJob.php to dispatch GeneratePermissionPostureFindingsJob after TenantPermissionService::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 with check_error=true in 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 from config('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.php with 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(): array method to app/Jobs/Alerts/EvaluateAlertsJob.php — queries Finding where finding_type='permission_posture', status IN ('new'), within the time window (filter by updated_at, not created_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 into handle() event collection alongside existing highDriftEvents() and compareFailedEvents() at ~L64-L67.
  • T028 [P] [US3] Add EVENT_PERMISSION_MISSING option to AlertRuleResource::eventTypeOptions() in app/Filament/Resources/AlertRuleResource.php at ~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 PruneStoredReportsCommand in app/Console/Commands/PruneStoredReportsCommand.php with signature stored-reports:prune {--days=}. Default days from config('tenantpilot.stored_reports.retention_days', 90). Deletes StoredReport::where('created_at', '<', now()->subDays($days)). Outputs count of deleted records.
  • T031 Add stored_reports.retention_days configuration key to config/tenantpilot.php with default value 90. Register stored-reports:prune command in daily schedule in routes/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 --dirty to fix formatting, then run vendor/bin/sail artisan test --compact --filter=PermissionPosture to 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)

  1. Complete Phase 2: Foundation (migrations, models, constants, badges)
  2. Complete Phase 3: User Story 1 (generator, job, health check hook)
  3. STOP and VALIDATE: Run vendor/bin/sail artisan test --compact --filter=PermissionPosture
  4. Posture findings are now generated automatically after each health check

Incremental Delivery

  1. Foundation → ready
  2. US1 (P1) → MVP: findings generated, auto-resolved, re-opened, tracked
  3. US2+US4 (P2) → stored reports + posture scores verified
  4. US3 (P3) → alerts for missing permissions
  5. 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