spec: 109 Tenant Review Pack Export v1 (CSV + ZIP)

This commit is contained in:
Ahmed Darrazi 2026-02-23 02:41:10 +01:00
parent e15eee8f26
commit 67e72012fb
2 changed files with 252 additions and 0 deletions

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Tenant Review Pack Export v1 (CSV + ZIP)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
All items pass. Spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,218 @@
# 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 (authenticated controller action, private storage)
- 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.
- **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); the download endpoint MUST enforce `REVIEW_PACK_VIEW` before streaming; non-members receive 404.
- **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 expired packs as `expired`, deletes their storage files, and optionally hard-deletes rows after a grace period.
- **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" (Owner/Manager, destructive) | — | "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.
---
## 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 (or added as part of this spec) in `config/filesystems.php` as a private, non-public disk.
- 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.