TenantAtlas/specs/109-review-pack-export/spec.md

22 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_packs links workspace_id + tenant_id); binary files on private exports disk.
  • 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_id always 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:

  1. Given a tenant with at least one permission_posture and one entra.admin_roles stored report and open findings, When a REVIEW_PACK_MANAGE user triggers "Generate pack", Then an OperationRun of type tenant.review_pack.generate is created and a job is enqueued; once processed the review_packs row has status = ready, a file exists at file_path on the exports disk, sha256 and file_size are populated, and a DB notification is sent to the initiator.
  2. 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.
  3. Given a ready pack, When a REVIEW_PACK_VIEW user clicks "Download latest", Then the file is returned with correct content-type and disposition headers and the content matches the stored sha256.
  4. Given the option include_pii = false, When the pack is generated, Then principal.display_name values 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:

  1. Given a ready ReviewPack 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 new review_packs row is created and the existing pack is returned.
  2. Given an active in-progress OperationRun of type tenant.review_pack.generate for 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:

  1. 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).
  2. Given a workspace member with REVIEW_PACK_VIEW but not REVIEW_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.
  3. Given a workspace member with REVIEW_PACK_MANAGE, When they trigger generation and download the result, Then both actions succeed.
  4. Given an Owner/Manager invoking expire or delete 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:

  1. Given a ready ReviewPack whose expires_at is in the past, When the nightly prune command runs, Then the pack status is updated to expired and its file is deleted from the exports disk.
  2. Given a ready ReviewPack whose expires_at is 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:

  1. Given the application scheduler, When the schedule is inspected programmatically, Then tenantpilot:posture:dispatch and tenantpilot:entra-roles:dispatch both 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 marked ready (not failed).
  • What happens when Storage::disk('exports') write fails mid-generation? The OperationRun is marked failed with reason_code review_pack.storage_failed; no ready review_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_pii is not specified in the generation request? Defaults to true (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 OperationRun type (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/delete) enforce ->requiresConfirmation().
  • 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_packs table with columns: id, workspace_id (FK NOT NULL), tenant_id (FK NOT NULL), operation_run_id (FK 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. Unique index on (workspace_id, tenant_id, fingerprint); additional indexes on (workspace_id, tenant_id, generated_at) and (status, expires_at).
  • FR-002: System MUST register REVIEW_PACK_VIEW and REVIEW_PACK_MANAGE in the canonical RBAC capability registry.
  • FR-003: System MUST implement a ReviewPackResource Filament 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.generate OperationRun 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.json MUST include a data_freshness object 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.
  • 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 redact principal.display_name (and equivalent PII fields) across all exported files, retaining object IDs and types.
  • 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); a ready unexpired 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 enforce REVIEW_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 sha256 and file_size for each generated pack.
  • FR-013: System MUST set expires_at on each pack (default: 90 days from generated_at; configurable via config('tenantpilot.review_pack.retention_days')).
  • FR-014: System MUST provide Artisan command tenantpilot:review-pack:prune that marks packs past expires_at as expired and deletes their storage files. Hard-delete of DB rows is off by default; when invoked with --hard-delete, rows that have been in expired status for longer than the grace period (default: 30 days, configurable via config('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:posture:dispatch and tenantpilot:entra-roles:dispatch in the Laravel console scheduler with at least daily frequency.
  • FR-016: System MUST remove or hide the sla_due event_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) or failed (with reason).
  • FR-018: On generation failure, system MUST record a stable reason_code (review_pack.generation_failed or review_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 + Delete require 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_roles types).
  • AlertRule (existing, modified): sla_due event_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 ready pack row and one file on disk — verified by concurrent trigger test.
  • SC-003: All generated ZIPs pass SHA-256 integrity verification against the sha256 value 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_at are 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_due event_type option no longer appears in the AlertRule form; existing AlertRule rows with sla_due are not corrupted (backward-compatible removal).
  • SC-008: The Laravel scheduler contains both tenantpilot:posture:dispatch and tenantpilot:entra-roles:dispatch assertable 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_MANAGE or add a separate REVIEW_PACK_DELETE capability? → A: Reuse REVIEW_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_freshness timestamps per source in summary.json so 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-delete flag on the prune command. Expired rows remain queryable for audit purposes until explicitly purged.
  • Q: exports disk storage backend — local disk or S3-compatible object storage? → A: Local disk (storage/app/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 exports filesystem disk is configured as a local private disk at storage/app/exports/ in config/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 OperationRun active-run dedupe guard follows the same pattern established by prior specs; tenant.review_pack.generate is added to the run_type registry.
  • The canonical RBAC capability registry location is established by Specs 001/066; REVIEW_PACK_VIEW and REVIEW_PACK_MANAGE are added following the same pattern.
  • principal.display_name is the primary PII field in Entra admin roles stored_report payloads; additional PII fields follow the same redaction pattern if discovered.
  • Default include_pii = true for 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 (open, acknowledged) and last_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.