## Summary - Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe). - Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes. - Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification. ## Key Changes - Review Pack generate action now: - Shows queued toast only when a new pack is actually created/queued. - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack. ## Tests - `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php` ## Notes - No global search behavior changes for ReviewPacks (still excluded). - Destructive actions remain confirmation-gated (`->requiresConfirmation()`). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #133
23 KiB
Feature Specification: Tenant Review Pack Export v1 (CSV + ZIP)
Feature Branch: 109-review-pack-export
Created: 2026-02-23
Status: Draft
Input: User description: "Tenant Review Pack Export v1 (CSV + ZIP) — exportierbares Audit-Artefakt (ZIP mit CSV + JSON) fuer MSP/Enterprise Tenant Reviews, DB-only UX, Fingerprint-Dedupe, RBAC-gesicherter Download, Retention-Pruning."
Spec Scope Fields (mandatory)
- Scope: workspace + tenant-context (
/admin/t/{tenant}/...) - Primary Routes:
/admin/t/{tenant}/review-packs— ReviewPackResource list/admin/t/{tenant}/review-packs/{id}— View detail- Download route (signed temporary URL via
URL::signedRoute(), private storage; URL expires after configurable TTL) - Tenant dashboard card (embedded widget/section)
- Data Ownership: workspace-owned + tenant-scoped (
review_packslinksworkspace_id+tenant_id); binary files on privateexportsdisk. - RBAC:
- Non-member → 404 (deny-as-not-found; workspace/tenant isolation enforced before any response).
REVIEW_PACK_VIEW→ list + download.REVIEW_PACK_MANAGE→ generate + download.- Missing capability but workspace member → 403.
- All capability checks go through the canonical capability registry (no raw strings in feature code).
For canonical-view compliance:
- Default filter when tenant-context is active: list prefiltered to current tenant;
workspace_id+tenant_idalways scoped server-side. - Cross-tenant leakage prevention: every query builder scope asserts
workspace_id= authed workspace +tenant_id= routed tenant; policy enforces both before any data is returned.
User Scenarios & Testing (mandatory)
User Story 1 — Generate and Download Review Pack (Priority: P1)
An MSP engineer wants to produce an auditable deliverable for a client tenant review. They open the Tenant Dashboard, see the "Tenant Review Pack" card, and click "Generate pack". The system creates a background job that collects the latest evidence (stored reports, findings, hardening status, recent operations), assembles a ZIP, and stores it privately. Once ready, the engineer receives a DB notification with a download link. They download the ZIP and find summary.json, findings.csv, operations.csv, hardening.json, reports/permission_posture.json, reports/entra_admin_roles.json, and metadata.json.
Why this priority: This is the core deliverable. Without it the feature has no value.
Independent Test: Create a tenant with stored reports + findings, trigger generation as a REVIEW_PACK_MANAGE user, assert OperationRun created, review_packs row transitions to ready, ZIP file exists on disk containing the expected file set.
Acceptance Scenarios:
- Given a tenant with at least one
permission_postureand oneentra.admin_rolesstored report and open findings, When aREVIEW_PACK_MANAGEuser triggers "Generate pack", Then anOperationRunof typetenant.review_pack.generateis created and a job is enqueued; once processed thereview_packsrow hasstatus = ready, a file exists atfile_pathon theexportsdisk,sha256andfile_sizeare populated, and a DB notification is sent to the initiator. - Given the ZIP is generated, When the engineer inspects its contents, Then the archive contains exactly:
summary.json,findings.csv,operations.csv,hardening.json,reports/permission_posture.json,reports/entra_admin_roles.json,metadata.json. - Given a ready pack, When a
REVIEW_PACK_VIEWuser clicks "Download latest", Then the file is returned with correct content-type and disposition headers and the content matches the storedsha256. - Given the option
include_pii = false, When the pack is generated, Thenprincipal.display_namevalues are replaced with a redacted placeholder in all exported files; UUIDs and object types are retained.
User Story 2 — Fingerprint Dedupe (Priority: P2)
An MSP engineer accidentally clicks "Generate pack" twice in quick succession. The system must not produce two identical packs. If a ready pack with the same fingerprint already exists and is not expired, the second request returns the existing pack without creating a new artifact.
Why this priority: Prevents storage waste and ensures idempotency — critical for scheduled and automated workflows.
Independent Test: Trigger pack generation twice with identical inputs; assert only one review_packs row with status = ready exists and only one file is on disk.
Acceptance Scenarios:
- Given a
readyReviewPack with fingerprint F exists and has not expired, When a new generation request arrives with the same inputs (same tenant, same options, same report fingerprints), Then no newreview_packsrow is created and the existing pack is returned. - Given an active in-progress
OperationRunof typetenant.review_pack.generatefor the same tenant, When a concurrent generate request arrives, Then the second request is rejected with a clear user-facing message ("generation already in progress").
User Story 3 — RBAC Enforcement (Priority: P2)
Access to review packs is strictly scoped: non-members cannot discover pack existence; view-only members can list and download but not generate; manage members can both generate and download; destructive actions (expire/delete) require a higher role and explicit confirmation.
Why this priority: Security and compliance requirement; prevents cross-tenant data leakage.
Independent Test: Test each role (no membership, view, manage) against each action (list, view, download, generate, expire) and assert correct HTTP status or Filament authorization result.
Acceptance Scenarios:
- Given a user with no workspace membership, When they attempt to list or download a tenant's review packs, Then they receive a 404 (deny-as-not-found).
- Given a workspace member with
REVIEW_PACK_VIEWbut notREVIEW_PACK_MANAGE, When they view the list and download a ready pack, Then both actions succeed; When they attempt to trigger generation, Then they receive a 403. - Given a workspace member with
REVIEW_PACK_MANAGE, When they trigger generation and download the result, Then both actions succeed. - Given an Owner/Manager invoking expire on a pack, When they invoke the destructive action, Then a confirmation dialog is shown before execution.
User Story 4 — Retention & Prune (Priority: P3)
Packs older than the configured retention period (default 90 days) are automatically expired and their storage files deleted by a nightly scheduled command.
Why this priority: Storage hygiene and compliance; required before production.
Independent Test: Create a ReviewPack with expires_at in the past, run the prune command, assert status = expired and storage file deleted.
Acceptance Scenarios:
- Given a
readyReviewPack whoseexpires_atis in the past, When the nightly prune command runs, Then the pack status is updated toexpiredand its file is deleted from theexportsdisk. - Given a
readyReviewPack whoseexpires_atis in the future, When the prune command runs, Then the pack and its file are unaffected.
User Story 5 — Scheduled Scan Wiring (Priority: P3)
Daily permission-posture and Entra-admin-roles scans are wired in the Laravel scheduler so that review pack generation normally consumes pre-cached stored reports rather than triggering on-demand heavy scans.
Why this priority: Operational readiness; ensures review packs use fresh data without blocking the generate request.
Independent Test: Assert the Laravel console schedule contains both dispatch commands with at least daily frequency.
Acceptance Scenarios:
- Given the application scheduler, When the schedule is inspected programmatically, Then
tenantpilot:posture:dispatchandtenantpilot:entra-roles:dispatchboth appear with at least daily frequency.
Edge Cases
- What happens when no stored reports exist for a tenant when generation is triggered? The job produces a pack with empty report sections and documents this in
summary.json; the pack is markedready(notfailed). - What happens when
Storage::disk('exports')write fails mid-generation? TheOperationRunis markedfailedwith reason_codereview_pack.storage_failed; noreadyreview_pack row is created; the initiator receives a failure notification. - What happens if the pack expires before the user clicks the download link from the notification? The download endpoint returns a user-friendly expired/not-found error; no secret information is revealed.
- What happens if
include_piiis not specified in the generation request? Defaults totrue(include display names); workspace-level override is out of scope v1. - How does the system handle tenants with very large finding sets? All open + recently-seen findings within the 30-day window are exported; query uses chunked iteration to avoid memory exhaustion; no pagination in CSV v1.
Requirements (mandatory)
Constitution alignment: This feature introduces:
- A new
OperationRuntype (tenant.review_pack.generate) with active-run and content-dedupe. Observability, identity, and visibility fully covered. - Write behavior (file write to private storage, DB row creation) — no Graph calls during the generate request; all evidence sourced from pre-existing
stored_reports. - Two new RBAC capabilities (
REVIEW_PACK_VIEW,REVIEW_PACK_MANAGE) registered in the canonical capability registry; no raw strings in feature code. - Filament Resource + Tenant Dashboard card with UI Action Matrix and UX-001 compliance (described below).
- Status badge on
review_packs.status— centralized badge mapping per BADGE-001. - Destructive actions (expire) enforce
->requiresConfirmation(). - Audit exemption (Expire action): Expire mutates the pack row (
status → expired,updated_atupdated) and deletes the storage file. The pack row itself is the audit trail (status change + timestamp +initiated_by_user_id). No separateAuditLogentry required — the mutation is observable via the persisted status field. - Authorization plane: tenant-context (
/admin/t/{tenant}/...); 404 for non-members, 403 for members lacking capability. - All generation and expiry operations tracked via
operation_runs; no observable mutation skips the OperationRun trail.
Constitution alignment (OPS-EX-AUTH-001): No OIDC/SAML handshakes. Not applicable.
Constitution alignment (BADGE-001): review_packs.status values (queued, generating, ready, failed, expired) must be registered in the shared badge registry with centralized color/label mappings. No ad-hoc inline color assignment. Tests assert each status renders the correct badge.
Constitution alignment (Filament Action Surfaces): UI Action Matrix provided below. Action Surface Contract satisfied.
Constitution alignment (UX-001):
- ReviewPackResource List: search + sort on
generated_at,status,tenant; status + date-range filters; meaningful empty state with specific title, explanation, and exactly one primary CTA. - View page uses Infolist (not a disabled edit form).
- "Generate" is a modal Header Action (not a dedicated Create page); exemption: generation is a background-job trigger, not a data-entry form — documented here.
- Status badges use BADGE-001.
- All form fields in the options modal placed inside Sections/Cards; no naked inputs.
Functional Requirements
- FR-001: System MUST provide a
review_packstable with columns:id,workspace_id(FK NOT NULL),tenant_id(FK NOT NULL),operation_run_id(FK nullable),initiated_by_user_id(FK → users, nullable),generated_at,fingerprint,previous_fingerprint(nullable),status(enum: queued/generating/ready/failed/expired),summary(jsonb),options(jsonb),file_disk,file_path,file_size(bigint),sha256,expires_at, timestamps. Partial unique index on(workspace_id, tenant_id, fingerprint)WHEREfingerprint IS NOT NULL AND status NOT IN ('expired', 'failed'); additional indexes on(workspace_id, tenant_id, generated_at)and(status, expires_at). - FR-002: System MUST register
REVIEW_PACK_VIEWandREVIEW_PACK_MANAGEin the canonical RBAC capability registry. - FR-003: System MUST implement a
ReviewPackResourceFilament Resource under Monitoring → Exports nav group with list, view, and modal-generate. - FR-004: System MUST implement a Tenant Dashboard card showing the latest pack's status,
generated_at,expires_at, and "Generate pack" (manage) / "Download latest" (view/manage) actions. - FR-005: System MUST implement
tenant.review_pack.generateOperationRun with active-run dedupe (unique active run per workspace+tenant+run_type) and content dedupe (fingerprint uniqueness per workspace+tenant). - FR-006: System MUST implement a queued Job that collects stored reports (
permission_posture,entra.admin_roles), findings (drift,permission_posture,entra_admin_roles), tenant hardening/write-safety status fields, and recent operation_runs (last 30 days), then assembles and stores a ZIP artifact. The job MUST NOT trigger Graph API calls; it operates exclusively on existing DB data.summary.jsonMUST include adata_freshnessobject with per-source timestamps indicating the age of each data input. - FR-007: The generated ZIP MUST contain:
summary.json,findings.csv,operations.csv(default on, togglable),hardening.json,reports/permission_posture.json,reports/entra_admin_roles.json,metadata.json. File order in ZIP MUST be stable and deterministic.metadata.jsonMUST include at minimum:generator_version(app version string),generated_at(ISO 8601),tenant_id,tenant_external_id,pack_fingerprint,options(include_pii, include_operations), anddata_model_version(schema version for forward compatibility). - FR-008: The export MUST NOT include webhook URLs, email recipient lists, tokens, client secrets, or raw Graph API response dumps.
- FR-009: When
include_pii = false, system MUST redactprincipal.display_nameacross all exported files, retaining object IDs and types. v1 scope:principal.display_nameis the only targeted PII field; additional PII fields discovered during implementation follow the same redaction pattern. - FR-010: System MUST compute a deterministic fingerprint:
sha256(tenant_id + include_pii + include_operations + sorted_report_fingerprints + max_finding_last_seen_at + hardening_status_tuple); areadyunexpired pack with the same fingerprint MUST be reused without creating a new artifact. - FR-011: Pack files MUST be written to
Storage::disk('exports')(private, non-public); downloads MUST use signed temporary URLs (URL::signedRoute()) with a configurable TTL; the URL generation endpoint MUST enforceREVIEW_PACK_VIEW(non-members receive 404); the signed download controller validates the signature but does not require an active session. - FR-012: System MUST compute and persist
sha256andfile_sizefor each generated pack. - FR-013: System MUST set
expires_aton each pack (default: 90 days fromgenerated_at; configurable viaconfig('tenantpilot.review_pack.retention_days')). - FR-014: System MUST provide Artisan command
tenantpilot:review-pack:prunethat marks packs pastexpires_atasexpiredand deletes their storage files. Hard-delete of DB rows is off by default; when invoked with--hard-delete, rows that have been inexpiredstatus for longer than the grace period (default: 30 days, configurable viaconfig('tenantpilot.review_pack.hard_delete_grace_days')) are permanently removed. Without--hard-delete, expired rows remain queryable for audit trails. - FR-015: System MUST wire
tenantpilot:entra-roles:dispatchin the Laravel console scheduler with at least daily frequency. System SHOULD wiretenantpilot:posture:dispatchwhen the command infrastructure exists (deferred — command does not yet exist; see research.md §7). - FR-016: System MUST remove or hide the
sla_dueevent_type option from the AlertRule form field without breaking existing AlertRule data. - FR-017: System MUST send a DB notification to the initiator when a pack transitions to
ready(with download link) orfailed(with reason). - FR-018: On generation failure, system MUST record a stable
reason_code(review_pack.generation_failedorreview_pack.storage_failed) on the OperationRun with a sanitized error message. - FR-019: Non-members accessing any review pack route MUST receive 404; members lacking the required capability MUST receive 403.
UI Action Matrix (mandatory)
| Surface | Location | Header Actions | Inspect Affordance | Row Actions (max 2 visible) | Bulk Actions | Empty-State CTA(s) | View Header Actions | Destructive Confirmation | Audit log? | Notes |
|---|---|---|---|---|---|---|---|---|---|---|
| ReviewPackResource (List) | app/Filament/Resources/ReviewPackResource.php | "Generate Pack" modal (REVIEW_PACK_MANAGE) | Clickable row to View page | "Download" (ready only, REVIEW_PACK_VIEW), "Expire" (REVIEW_PACK_MANAGE, destructive + requiresConfirmation) | — | "No review packs yet" + "Generate first pack" CTA | — | Expire requires requiresConfirmation() | Yes (OperationRun) | Generate opens options modal with include_pii toggle |
| ReviewPackResource (View) | app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php | "Download" (REVIEW_PACK_VIEW), "Regenerate" (REVIEW_PACK_MANAGE, Owner/Manager) | — | — | — | — | Download, Regenerate | Regenerate requires requiresConfirmation() if ready pack exists | Yes (OperationRun) | Infolist layout; status badge per BADGE-001 |
| Tenant Dashboard Card | app/Filament/Widgets/TenantReviewPackCard.php | — | — | "Generate pack" (REVIEW_PACK_MANAGE), "Download latest" (REVIEW_PACK_VIEW) | — | "No pack yet — Generate first" | — | — | Yes (via OperationRun) | Shows latest pack status, generated_at, expires_at |
Key Entities
- ReviewPack: Represents a generated ZIP artifact scoped to a workspace + tenant. Carries status lifecycle (queued → generating → ready/failed/expired), fingerprint for dedupe, file location references (disk, path, sha256, size), retention deadline, and link to the generating OperationRun.
- OperationRun (
tenant.review_pack.generate): Tracks generation lifecycle. Provides observability, dedupe anchor, and failure reason_code. Existing entity; new run_type added. - StoredReport (existing, unchanged): Evidence snapshots consumed by the pack generator (
permission_posture,entra.admin_roles). - Finding (existing, unchanged): Actionable deviations exported to
findings.csv(drift,permission_posture,entra_admin_rolestypes). - AlertRule (existing, modified):
sla_dueevent_type option removed from the form field.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: A user can trigger pack generation and receive a downloadable ZIP within 60 seconds for a tenant with up to 1,000 findings and 10 stored reports under normal background worker load.
- SC-002: Duplicate generation requests with identical inputs produce exactly one
readypack row and one file on disk — verified by concurrent trigger test. - SC-003: All generated ZIPs pass SHA-256 integrity verification against the
sha256value stored in the database (byte-for-byte match on re-download). - SC-004: Non-members receive a 404 with no pack information disclosed; view-only members cannot trigger generation (403); enforced server-side in all code paths.
- SC-005: Packs past their
expires_atare expired and their files deleted by the nightly prune command with no manual intervention; verified by test and schedule assertion. - SC-006: The ReviewPack list renders with search, sort by generated_at and status, and status + date-range filters; the empty state displays a specific title, explanation, and exactly one primary CTA.
- SC-007: The
sla_dueevent_type option no longer appears in the AlertRule form; existing AlertRule rows withsla_dueare not corrupted (backward-compatible removal). - SC-008: The Laravel scheduler contains both
tenantpilot:posture:dispatchandtenantpilot:entra-roles:dispatchassertable via a Pest schedule test without full job execution.
Clarifications
Session 2026-02-23
- Q: Download delivery mechanism (signed temporary URL vs session-authenticated stream vs both)? → A: Signed temporary URL (
URL::signedRoute(), self-contained, expires after configurable TTL). Notification links are self-contained; RBAC check happens at URL generation time. - Q: Expire/Delete authorization — reuse
REVIEW_PACK_MANAGEor add a separateREVIEW_PACK_DELETEcapability? → A: ReuseREVIEW_PACK_MANAGE; the->requiresConfirmation()dialog provides the safety gate. A dedicated delete capability can be split out later if needed. - Q: Stale data threshold — should the generation job trigger on-demand Graph scans if stored reports are older than 24h? → A: No. Generate with existing data (DB-only, no Graph calls). Include
data_freshnesstimestamps per source insummary.jsonso the reviewer can judge staleness and optionally trigger manual scans before regenerating. - Q: Prune grace period for hard-delete — how long do expired rows stay in the DB, and is hard-delete on by default? → A: 30-day grace period after expiry. Hard-delete is off by default; operators opt in via
--hard-deleteflag on the prune command. Expired rows remain queryable for audit purposes until explicitly purged. - Q:
exportsdisk storage backend — local disk or S3-compatible object storage? → A: Local disk (storage/app/private/exports/) with a persistent Docker volume mapped in Dokploy. Downloads streamed via signed route controller. S3 can be swapped later by changing the disk config without code changes.
Assumptions
stored_reports,findings,operation_runs, tenant, and workspace models exist per Specs 104/105/108; this spec adds no structural changes to those tables.- The
exportsfilesystem disk is configured as a local private disk atstorage/app/private/exports/inconfig/filesystems.php. In Dokploy deployments, this path MUST be mapped to a persistent Docker volume. S3-compatible storage can be substituted later by changing the disk driver without code changes. - The
OperationRunactive-run dedupe guard follows the same pattern established by prior specs;tenant.review_pack.generateis added to the run_type registry. - The canonical RBAC capability registry location is established by Specs 001/066;
REVIEW_PACK_VIEWandREVIEW_PACK_MANAGEare added following the same pattern. principal.display_nameis the primary PII field in Entra admin roles stored_report payloads; additional PII fields follow the same redaction pattern if discovered.- Default
include_pii = truefor internal MSP use; workspace-level override is out of scope for v1. - "Force regenerate" (superseding a ready unexpired pack) is an optional Owner/Manager action; if deferred, the UI simply omits the button in v1.
- Findings exported are scoped to
status IN (new, acknowledged)andlast_seen_at >= now() - 30 days; exact scope confirmed against Finding model scopes during implementation. - ZIP assembly uses PHP ZipArchive with deterministic, alphabetical file insertion order for stable fingerprinting.