# 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 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) enforce `->requiresConfirmation()`.
- Audit exemption (Expire action): Expire mutates the pack row (`status → expired`, `updated_at` updated) and deletes the storage file. The pack row itself is the audit trail (status change + timestamp + `initiated_by_user_id`). No separate `AuditLog` entry 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_packs` table 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)` WHERE `fingerprint 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_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. `metadata.json` MUST include at minimum: `generator_version` (app version string), `generated_at` (ISO 8601), `tenant_id`, `tenant_external_id`, `pack_fingerprint`, `options` (include_pii, include_operations), and `data_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 redact `principal.display_name` across all exported files, retaining object IDs and types. v1 scope: `principal.display_name` is 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)`; 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:entra-roles:dispatch` in the Laravel console scheduler with at least daily frequency. System SHOULD wire `tenantpilot:posture:dispatch` when the command infrastructure exists (deferred — command does not yet exist; see research.md §7).
- **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 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_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/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 `exports` filesystem disk is configured as a local private disk at `storage/app/private/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 (new, 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.
