plan: 109 review pack export - research, data model, contracts, quickstart

This commit is contained in:
Ahmed Darrazi 2026-02-23 03:12:33 +01:00
parent d4cbe7b722
commit ce8aa1c54b
6 changed files with 1008 additions and 3 deletions

View File

@ -37,6 +37,8 @@ ## Active Technologies
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -56,8 +58,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 107-workspace-chooser: Added PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4
- 106-required-permissions-sidebar-context: Middleware sidebar-context fix for workspace-scoped pages
- 105-entra-admin-roles-evidence-findings: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,226 @@
# API Contracts: 109 — Tenant Review Pack Export v1
**Date**: 2026-02-23
**Branch**: `109-review-pack-export`
---
## Overview
This feature introduces three interaction surfaces:
1. **Filament Resource** — ReviewPackResource (list, view pages + modal generate action)
2. **Filament Widget** — Tenant Dashboard card
3. **HTTP Route** — Signed download endpoint
All Filament interactions are handled through Livewire/Filament's built-in request model (no custom REST endpoints). The only non-Filament HTTP endpoint is the signed file download route.
---
## 1. Signed Download Route
### `GET /admin/review-packs/{reviewPack}/download`
**Route Name:** `admin.review-packs.download`
**Authentication:** Signed URL (via `URL::signedRoute()`). No active session required.
**Authorization:** URL is generated only if requesting user has `REVIEW_PACK_VIEW` capability in the pack's workspace+tenant scope. Non-members cannot obtain a valid URL. Signature validation is done by middleware.
**Parameters:**
| Parameter | Location | Type | Required | Notes |
|-----------|----------|------|----------|-------|
| `reviewPack` | path | integer | yes | ReviewPack record ID |
| `signature` | query | string | yes | Auto-injected by `URL::signedRoute()` |
| `expires` | query | integer | yes | Auto-injected by `URL::signedRoute()` |
**Response (Success — 200):**
```
Content-Type: application/zip
Content-Disposition: attachment; filename="review-pack-{tenant_external_id}-{YYYY-MM-DD}.zip"
Content-Length: {file_size}
X-Review-Pack-SHA256: {sha256}
{binary ZIP content streamed from exports disk}
```
**Response (Expired Signature — 403):**
```json
{ "message": "Invalid signature." }
```
**Response (Pack Expired or Not Ready — 404):**
```json
{ "message": "Not Found" }
```
**Response (Pack Not Found / Wrong Workspace — 404):**
```json
{ "message": "Not Found" }
```
**Implementation Notes:**
- Route middleware: `signed` (Laravel built-in)
- Controller validates `reviewPack->status === 'ready'` before streaming
- File streamed via `Storage::disk($pack->file_disk)->download($pack->file_path, ...)`
- Response includes `X-Review-Pack-SHA256` header for client-side integrity verification
---
## 2. Signed URL Generation (Internal)
Not an HTTP endpoint. URL is generated server-side when user clicks "Download" in Filament.
```php
// Service method
public function generateDownloadUrl(ReviewPack $pack): string
{
return URL::signedRoute(
'admin.review-packs.download',
['reviewPack' => $pack->id],
now()->addMinutes(config('tenantpilot.review_pack.download_url_ttl_minutes', 60))
);
}
```
Authorization check happens before URL generation (Gate/Policy), not at download time (signature-only at download).
---
## 3. Filament Actions Contract
### ReviewPackResource — Header Action: "Generate Pack"
**Trigger:** Modal action button on List page header
**Capability Required:** `REVIEW_PACK_MANAGE`
**Modal Fields:**
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `include_pii` | Toggle | `true` | Label: "Include display names (PII)" |
| `include_operations` | Toggle | `true` | Label: "Include operations log" |
**Behavior:**
1. Check for active `OperationRun` of type `tenant.review_pack.generate` for this tenant → if exists, show error notification "Generation already in progress" and abort.
2. Compute input fingerprint → check for existing `ready` unexpired pack with same fingerprint → if exists, show info notification "Identical pack already exists" with download link and abort.
3. Create `OperationRun` (type: `tenant.review_pack.generate`, status: queued).
4. Create `ReviewPack` (status: queued, linked to OperationRun).
5. Dispatch `GenerateReviewPackJob`.
6. Show success notification: "Review pack generation started."
### ReviewPackResource — Row Action: "Download"
**Visibility:** `status === 'ready'`
**Capability Required:** `REVIEW_PACK_VIEW`
**Behavior:** Generate signed URL → open in new tab (`->openUrlInNewTab()`)
### ReviewPackResource — Row Action: "Expire"
**Visibility:** `status === 'ready'`
**Capability Required:** `REVIEW_PACK_MANAGE`
**Destructive:** Yes → `->requiresConfirmation()` + `->color('danger')`
**Behavior:**
1. Set `status = expired`.
2. Delete file from `exports` disk.
3. Show success notification: "Review pack expired."
### ReviewPack View Page — Header Action: "Regenerate"
**Capability Required:** `REVIEW_PACK_MANAGE`
**Destructive:** Yes (if a ready pack exists) → `->requiresConfirmation()`
**Behavior:** Same as "Generate Pack" but with the current pack's options pre-filled and `previous_fingerprint` set to the current pack's fingerprint.
---
## 4. Tenant Dashboard Widget Contract
### `TenantReviewPackCard` Widget
**Location:** Tenant dashboard
**Data:** Latest `ReviewPack` for current tenant (eager-loaded)
**Display States:**
| Pack State | Card Content | Actions |
|------------|-------------|---------|
| No pack exists | "No review pack yet" | "Generate first pack" (REVIEW_PACK_MANAGE) |
| queued / generating | Status badge + "Generation in progress" | — |
| ready | Status badge + generated_at + expires_at + file_size | "Download" (REVIEW_PACK_VIEW) + "Generate new" (REVIEW_PACK_MANAGE) |
| failed | Status badge + failure reason (sanitized) | "Retry" = Generate (REVIEW_PACK_MANAGE) |
| expired | Status badge + "Expired on {date}" | "Generate new" (REVIEW_PACK_MANAGE) |
---
## 5. Job Contract
### `GenerateReviewPackJob`
**Queue:** `default`
**Implements:** `ShouldQueue`
**Unique:** Via `OperationRun` active-run dedupe (not Laravel's `ShouldBeUnique`)
**Input:**
```php
public function __construct(
public int $reviewPackId,
public int $operationRunId,
) {}
```
**Steps:**
1. Load ReviewPack + OperationRun; abort if either missing.
2. Mark OperationRun as `running`, ReviewPack as `generating`.
3. Collect data: StoredReports, Findings, Tenant hardening, recent OperationRuns.
4. Compute `data_freshness` timestamps per source.
5. Build in-memory file map (filenames → content).
6. Apply PII redaction if `options.include_pii === false`.
7. Assemble ZIP to temp file (alphabetical insertion order).
8. Compute SHA-256 of ZIP.
9. Store ZIP on `exports` disk.
10. Update ReviewPack: status=ready, fingerprint, sha256, file_size, file_path, file_disk, generated_at, summary.
11. Mark OperationRun as `completed`, outcome=`success`.
12. Send `ReviewPackStatusNotification` (ready) to initiator.
**On Failure:**
1. Update ReviewPack: status=failed.
2. Mark OperationRun as `completed`, outcome=`failed`, with `reason_code` in context.
3. Send `ReviewPackStatusNotification` (failed) to initiator.
4. Re-throw exception for queue worker visibility.
---
## 6. Artisan Command Contract
### `tenantpilot:review-pack:prune`
**Signature:** `tenantpilot:review-pack:prune {--hard-delete}`
**Behavior:**
1. Query ReviewPacks where `status = 'ready'` AND `expires_at < now()`.
2. For each: set `status = expired`, delete file from disk.
3. If `--hard-delete`: query ReviewPacks where `status = 'expired'` AND `updated_at < now() - grace_days`. Hard-delete these rows.
4. Output summary: `{n} packs expired, {m} packs hard-deleted`.
**Schedule:** `daily()` + `withoutOverlapping()`
---
## 7. Notification Contract
### `ReviewPackStatusNotification`
**Channel:** Database (Filament notification system)
**Recipients:** Initiator user (via `initiated_by_user_id`)
**Payload (ready):**
```
title: "Review pack ready"
body: "Review pack for {tenant_name} is ready for download."
actions: [View → ViewReviewPack page URL]
```
**Payload (failed):**
```
title: "Review pack generation failed"
body: "Review pack for {tenant_name} could not be generated: {sanitized_reason}."
actions: [View → ViewReviewPack page URL]
```

View File

@ -0,0 +1,274 @@
# Data Model: 109 — Tenant Review Pack Export v1
**Date**: 2026-02-23
**Branch**: `109-review-pack-export`
---
## New Entities
### 1. `review_packs` Table
| Column | Type | Constraints | Notes |
|--------|------|-------------|-------|
| `id` | `bigint` | PK, auto-increment | |
| `workspace_id` | `bigint` | FK → workspaces, NOT NULL, cascadeOnDelete | Auto-derived via `DerivesWorkspaceIdFromTenant` |
| `tenant_id` | `bigint` | FK → tenants, NOT NULL, cascadeOnDelete | |
| `operation_run_id` | `bigint` | FK → operation_runs, nullable, nullOnDelete | Link to generating run; null if run is purged |
| `initiated_by_user_id` | `bigint` | FK → users, nullable, nullOnDelete | User who triggered generation |
| `status` | `string` | NOT NULL, default `'queued'` | Enum string: queued, generating, ready, failed, expired |
| `fingerprint` | `string(64)` | nullable | SHA-256 of inputs for content dedupe |
| `previous_fingerprint` | `string(64)` | nullable | Prior pack fingerprint for lineage |
| `summary` | `jsonb` | default `'{}'` | `data_freshness`, counts, etc. |
| `options` | `jsonb` | default `'{}'` | `include_pii`, `include_operations` flags |
| `file_disk` | `string` | nullable | Storage disk name (e.g., `exports`) |
| `file_path` | `string` | nullable | Relative file path within disk |
| `file_size` | `bigint` | nullable | File size in bytes |
| `sha256` | `string(64)` | nullable | SHA-256 of the generated ZIP file |
| `generated_at` | `timestampTz` | nullable | When generation completed |
| `expires_at` | `timestampTz` | nullable | Retention deadline |
| `created_at` | `timestampTz` | auto | |
| `updated_at` | `timestampTz` | auto | |
**Indexes:**
| Index | Type | Purpose |
|-------|------|---------|
| `(workspace_id, tenant_id, fingerprint)` | unique (where fingerprint IS NOT NULL) | Content dedupe within a tenant |
| `(workspace_id, tenant_id, generated_at)` | btree | List page pagination/sort |
| `(status, expires_at)` | btree | Prune command query |
**Partial Unique Index (PostgreSQL):**
```sql
CREATE UNIQUE INDEX review_packs_fingerprint_unique
ON review_packs (workspace_id, tenant_id, fingerprint)
WHERE fingerprint IS NOT NULL AND status NOT IN ('expired', 'failed');
```
Rationale: Only active (non-expired, non-failed) packs participate in fingerprint uniqueness. Expired and failed packs can share a fingerprint with newer ready packs.
---
### 2. `ReviewPack` Model
**Namespace:** `App\Models\ReviewPack`
**Traits:**
- `HasFactory`
- `DerivesWorkspaceIdFromTenant` (auto-sets `workspace_id` from tenant)
**Guarded:** `[]` (mass assignment protection via policy layer)
**Casts:**
```php
protected function casts(): array
{
return [
'summary' => 'array',
'options' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
'file_size' => 'integer',
];
}
```
**Relationships:**
```php
public function workspace(): BelongsTo → Workspace::class
public function tenant(): BelongsTo → Tenant::class
public function operationRun(): BelongsTo → OperationRun::class
public function initiator(): BelongsTo → User::class (FK: initiated_by_user_id)
```
**Scopes:**
```php
public function scopeReady(Builder $q): Builder → where('status', 'ready')
public function scopeExpired(Builder $q): Builder → where('status', 'expired')
public function scopePastRetention(Builder $q): Builder → where('expires_at', '<', now())
public function scopeForTenant(Builder $q, int $tenantId): Builder
public function scopeLatestReady(Builder $q): Builder → ready()->latest('generated_at')
```
**Constants:**
```php
const STATUS_QUEUED = 'queued';
const STATUS_GENERATING = 'generating';
const STATUS_READY = 'ready';
const STATUS_FAILED = 'failed';
const STATUS_EXPIRED = 'expired';
```
---
### 3. `ReviewPackStatus` Enum
**Namespace:** `App\Support\ReviewPackStatus`
```php
enum ReviewPackStatus: string
{
case Queued = 'queued';
case Generating = 'generating';
case Ready = 'ready';
case Failed = 'failed';
case Expired = 'expired';
}
```
---
## Modified Entities
### 4. `OperationRunType` Enum — Add case
```php
case ReviewPackGenerate = 'tenant.review_pack.generate';
```
Added after `EntraAdminRolesScan`. Value follows existing `namespace.action` convention.
---
### 5. `BadgeDomain` Enum — Add case
```php
case ReviewPackStatus = 'review_pack_status';
```
**Badge Mapper:** `App\Support\Badges\Mappers\ReviewPackStatusBadge`
| Value | Label | Color |
|-------|-------|-------|
| `queued` | Queued | warning |
| `generating` | Generating | info |
| `ready` | Ready | success |
| `failed` | Failed | danger |
| `expired` | Expired | gray |
---
### 6. `Capabilities` — Add constants
```php
// Review packs
public const REVIEW_PACK_VIEW = 'review_pack.view';
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
```
Added after Entra roles section. Auto-discovered by `Capabilities::all()` via reflection.
---
### 7. `config/filesystems.php` — Add `exports` disk
```php
'exports' => [
'driver' => 'local',
'root' => storage_path('app/private/exports'),
'serve' => false,
'throw' => true,
],
```
---
### 8. `config/tenantpilot.php` — Add `review_pack` section
```php
'review_pack' => [
'retention_days' => (int) env('TENANTPILOT_REVIEW_PACK_RETENTION_DAYS', 90),
'hard_delete_grace_days' => (int) env('TENANTPILOT_REVIEW_PACK_HARD_DELETE_GRACE_DAYS', 30),
'download_url_ttl_minutes' => (int) env('TENANTPILOT_REVIEW_PACK_DOWNLOAD_URL_TTL_MINUTES', 60),
'include_pii_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_PII_DEFAULT', true),
'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true),
],
```
---
## Entity Relationship Diagram (Simplified)
```
Workspace (1) ──< ReviewPack (N)
Tenant (1) ──< ReviewPack (N)
OperationRun (1) ──< ReviewPack (0..1)
User (1) ──< ReviewPack (N) [initiated_by_user_id]
```
ReviewPack reads (but does not mutate) during generation:
- StoredReport (via workspace_id + tenant_id + report_type)
- Finding (via tenant_id + status + severity)
- Tenant (RBAC hardening fields)
- OperationRun (recent runs, last 30 days)
---
## State Machine: `review_packs.status`
```
┌──────────┐
Create ─────>│ queued │
└─────┬────┘
│ Job starts
v
┌──────────────┐
│ generating │
└──┬────────┬──┘
Success │ │ Failure
v v
┌───────┐ ┌────────┐
│ ready │ │ failed │
└───┬───┘ └────────┘
│ expires_at passed (prune cmd)
v
┌─────────┐
│ expired │
└─────────┘
```
Transitions are one-way. A failed pack is never retried (user triggers a new generation). An expired pack is never un-expired.
---
## Migration Order
1. `YYYY_MM_DD_HHMMSS_create_review_packs_table.php` — Creates table, indexes, partial unique index.
No modifications to existing tables required. `OperationRunType`, `BadgeDomain`, `Capabilities`, and config changes are code-only (no migrations).
---
## Factory: `ReviewPackFactory`
```php
# database/factories/ReviewPackFactory.php
class ReviewPackFactory extends Factory
{
protected $model = ReviewPack::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'status' => ReviewPackStatus::Ready->value,
'fingerprint' => fake()->sha256(),
'summary' => ['data_freshness' => [...]],
'options' => ['include_pii' => true, 'include_operations' => true],
'file_disk' => 'exports',
'file_path' => 'packs/' . fake()->uuid() . '.zip',
'file_size' => fake()->numberBetween(1024, 1024 * 1024),
'sha256' => fake()->sha256(),
'generated_at' => now(),
'expires_at' => now()->addDays(90),
];
}
// States
public function queued(): static → status queued, nullify file fields + generated_at
public function generating(): static → status generating, nullify file fields + generated_at
public function ready(): static → status ready (default)
public function failed(): static → status failed, nullify file fields
public function expired(): static → status expired, expires_at in the past
}
```

View File

@ -0,0 +1,131 @@
# Implementation Plan: Tenant Review Pack Export v1 (CSV + ZIP)
**Branch**: `109-review-pack-export` | **Date**: 2026-02-23 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/109-review-pack-export/spec.md`
## Summary
Implement an exportable audit artifact (ZIP with CSV + JSON) for MSP/Enterprise tenant reviews. The system collects pre-cached stored reports, findings, tenant hardening status, and recent operations from the database (no Graph API calls), assembles them into a deterministic ZIP archive, and stores it on a private local disk. Downloads use signed temporary URLs. Features include fingerprint-based content deduplication, RBAC-gated access (`REVIEW_PACK_VIEW` / `REVIEW_PACK_MANAGE`), retention pruning with configurable expiry, and OperationRun-based observability. The Filament UI provides a ReviewPackResource (list + view), a Tenant Dashboard card, and a modal-based generation trigger.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Framework v12
**Storage**: PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts
**Testing**: Pest v4 (Feature tests with Livewire component testing)
**Target Platform**: Linux server (Sail/Docker local, Dokploy VPS staging/production)
**Project Type**: web (Laravel monolith with Filament admin panel)
**Performance Goals**: Pack generation < 60s for 1,000 findings + 10 stored reports; download streaming with no memory loading of full file
**Constraints**: DB-only generation (no Graph API calls); chunked iteration for large finding sets; temp file for ZIP assembly (no full in-memory ZIP)
**Scale/Scope**: Multi-workspace, multi-tenant; packs are tenant-scoped; typical pack size < 10 MB
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Rule | Status | Notes |
|------|--------|-------|
| **Inventory-first** | PASS | ReviewPack reads from existing stored_reports + findings (pre-cached inventory data). No new inventory sources introduced. |
| **Read/write separation** | PASS | Generation is a queued job (async write). User action is enqueue-only. Destructive actions (expire/delete) require `->requiresConfirmation()`. All writes audit-logged via OperationRun. |
| **Graph contract path** | PASS | No Graph API calls in this feature. All data sourced from DB. FR-006 explicitly forbids Graph calls during generation. |
| **Deterministic capabilities** | PASS | `REVIEW_PACK_VIEW` and `REVIEW_PACK_MANAGE` added to canonical `Capabilities.php` registry. Discoverable via `Capabilities::all()` reflection. No raw strings. |
| **RBAC-UX: two planes** | PASS | All routes under `/admin` plane. Tenant-context routes scoped via `/admin/t/{tenant}/...`. No `/system` plane involvement. |
| **RBAC-UX: non-member = 404** | PASS | Non-member workspace/tenant access returns 404 (deny-as-not-found). Policy enforces workspace_id + tenant_id before any data returned. Download endpoint returns 404 for non-existent/inaccessible packs. |
| **RBAC-UX: member missing cap = 403** | PASS | Members without `REVIEW_PACK_VIEW` get 403 on list/download. Members without `REVIEW_PACK_MANAGE` get 403 on generate/expire. |
| **RBAC-UX: destructive confirmation** | PASS | Expire and Delete actions use `->requiresConfirmation()` with clear warning text. Regenerate requires confirmation if a ready pack exists. |
| **RBAC-UX: global search** | N/A | ReviewPackResource does not participate in global search (no `$recordTitleAttribute`). Intentionally excluded. |
| **Workspace isolation** | PASS | `DerivesWorkspaceIdFromTenant` auto-sets workspace_id. All queries scope by workspace_id + tenant_id. Download controller validates pack ownership before streaming. |
| **Tenant isolation** | PASS | Partial unique index scoped to (workspace_id, tenant_id, fingerprint). All scopes include tenant_id. Dashboard widget reads only current tenant packs. |
| **Run observability** | PASS | `OperationRun` of type `tenant.review_pack.generate` tracks lifecycle. Active-run dedupe via partial unique index. Failure reason stored in `context['reason_code']`. |
| **Automation** | PASS | Prune command uses `withoutOverlapping()`. GenerateReviewPackJob unique via OperationRun active dedupe (existing pattern). |
| **Data minimization** | PASS | FR-008: no webhooks, tokens, secrets, or raw Graph dumps exported. FR-009: PII redaction via `include_pii` toggle. |
| **BADGE-001** | PASS | `ReviewPackStatus` added to `BadgeDomain` enum with `ReviewPackStatusBadge` mapper. All 5 statuses mapped. |
| **Filament Action Surface Contract** | PASS | UI Action Matrix in spec. List: header action (Generate), row actions (Download + Expire), empty state CTA. View: header actions (Download + Regenerate). Card: inline actions. Destructive actions confirmed. |
| **UX-001** | PASS | View page uses Infolist. Empty state has specific title + explanation + 1 CTA. Status badges use BADGE-001. Generate modal fields in Sections. Table has search/sort/filters. |
**Post-design re-evaluation**: All checks PASS. No violations found.
## Project Structure
### Documentation (this feature)
```text
specs/109-review-pack-export/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0: resolved unknowns
├── data-model.md # Phase 1: entity design
├── quickstart.md # Phase 1: implementation guide
├── contracts/ # Phase 1: API + action contracts
│ └── api-contracts.md
├── checklists/
│ └── requirements.md
└── tasks.md # Phase 2 output (created by /speckit.tasks)
```
### Source Code (repository root)
```text
app/
├── Console/Commands/
│ └── PruneReviewPacksCommand.php
├── Filament/
│ ├── Resources/
│ │ └── ReviewPackResource.php
│ │ └── Pages/
│ │ ├── ListReviewPacks.php
│ │ └── ViewReviewPack.php
│ └── Widgets/
│ └── TenantReviewPackCard.php
├── Http/Controllers/
│ └── ReviewPackDownloadController.php
├── Jobs/
│ └── GenerateReviewPackJob.php
├── Models/
│ └── ReviewPack.php
├── Notifications/
│ └── ReviewPackStatusNotification.php
├── Services/
│ └── ReviewPackService.php
└── Support/
├── ReviewPackStatus.php
└── Badges/Mappers/
└── ReviewPackStatusBadge.php
config/
├── filesystems.php # Modified: add exports disk
└── tenantpilot.php # Modified: add review_pack section
database/
├── factories/
│ └── ReviewPackFactory.php
└── migrations/
└── XXXX_create_review_packs_table.php
routes/
├── web.php # Modified: add download route
└── console.php # Modified: add prune schedule entry
tests/Feature/ReviewPack/
├── ReviewPackGenerationTest.php
├── ReviewPackDownloadTest.php
├── ReviewPackRbacTest.php
├── ReviewPackPruneTest.php
├── ReviewPackResourceTest.php
└── ReviewPackWidgetTest.php
```
**Structure Decision**: Standard Laravel monolith structure. All new files follow existing directory conventions. No new base folders introduced.
## Complexity Tracking
No Constitution violations detected. No complexity justifications needed.
## Filament v5 Agent Output Contract
1. **Livewire v4.0+ compliance**: Yes. Filament v5 requires Livewire v4 — all components are Livewire v4 compatible.
2. **Provider registration**: Panel provider already registered in `bootstrap/providers.php`. No new panel required.
3. **Global search**: ReviewPackResource does NOT participate in global search. No `$recordTitleAttribute` set. Intentional.
4. **Destructive actions**: Expire action uses `->action(...)` + `->requiresConfirmation()` + `->color('danger')`. Regenerate uses `->requiresConfirmation()` when a ready pack exists. Hard-delete in prune command requires explicit `--hard-delete` flag.
5. **Asset strategy**: No custom frontend assets. Standard Filament components only. `php artisan filament:assets` already in deploy pipeline.
6. **Testing plan**: Pest Feature tests covering: generation job (happy + failure paths), download controller (signed URL, expired, RBAC), RBAC enforcement (404/403 matrix), prune command (expire + hard-delete), Filament Resource (Livewire component tests for list/view pages + actions), Dashboard widget (Livewire component test).

View File

@ -0,0 +1,185 @@
# Quickstart: 109 — Tenant Review Pack Export v1
**Date**: 2026-02-23
**Branch**: `109-review-pack-export`
---
## Prerequisites
- Sail services running (`vendor/bin/sail up -d`)
- Database migrated to latest
- At least one workspace with a connected tenant
- Tenant has stored reports (permission_posture and/or entra.admin_roles) and findings
---
## Implementation Phases
### Phase 1: Foundation (Data Layer + Config)
**Goal:** Database table, model, factory, enum additions, config entries.
1. **Migration**: `create_review_packs_table`
- See [data-model.md](data-model.md) for full schema
- Includes partial unique index on `(workspace_id, tenant_id, fingerprint)` for non-expired/failed packs
2. **Model**: `ReviewPack`
- Uses `DerivesWorkspaceIdFromTenant`
- Casts: summary/options as array, generated_at/expires_at as datetime
- Relationships: workspace, tenant, operationRun, initiator
- Scopes: ready, expired, pastRetention, forTenant, latestReady
3. **Factory**: `ReviewPackFactory` with states: queued, generating, ready, failed, expired
4. **Enum**: Add `ReviewPackGenerate` to `OperationRunType`
5. **Enum**: Add `ReviewPackStatus` (standalone enum at `App\Support\ReviewPackStatus`)
6. **Config**:
- `config/filesystems.php``exports` disk
- `config/tenantpilot.php``review_pack` section
7. **Capabilities**: Add `REVIEW_PACK_VIEW` and `REVIEW_PACK_MANAGE` to `Capabilities.php`
8. **Badge**: Add `ReviewPackStatus` to `BadgeDomain` + create `ReviewPackStatusBadge` mapper
**Run**: `vendor/bin/sail artisan migrate && vendor/bin/sail artisan test --compact --filter=ReviewPack`
---
### Phase 2: Service Layer + Job
**Goal:** Generation logic, ZIP assembly, fingerprint computation.
1. **Service**: `ReviewPackService`
- `generate(Tenant, User, array $options): ReviewPack` — orchestrates creation
- `computeFingerprint(Tenant, array $options): string` — deterministic hash
- `generateDownloadUrl(ReviewPack): string` — signed URL
- `findExistingPack(Tenant, string $fingerprint): ?ReviewPack` — dedupe check
2. **Job**: `GenerateReviewPackJob`
- Collects data from StoredReport, Finding, Tenant, OperationRun
- Builds file map → assembles ZIP → stores on exports disk
- Updates ReviewPack status + metadata
- Sends notification on completion/failure
3. **Notification**: `ReviewPackStatusNotification`
- Database channel
- Ready: includes view page link
- Failed: includes sanitized reason
**Run**: `vendor/bin/sail artisan test --compact --filter=GenerateReviewPack`
---
### Phase 3: Download Controller
**Goal:** Signed URL download endpoint.
1. **Controller**: `ReviewPackDownloadController`
- Single `__invoke` method
- Validates pack exists + status is ready
- Streams file with proper headers (Content-Type, Content-Disposition, SHA256)
2. **Route**: `GET /admin/review-packs/{reviewPack}/download`
- Named `admin.review-packs.download`
- Middleware: `signed`
**Run**: `vendor/bin/sail artisan test --compact --filter=ReviewPackDownload`
---
### Phase 4: Filament UI
**Goal:** Resource, view page, dashboard widget.
1. **Resource**: `ReviewPackResource`
- List page: table with status badge, generated_at, expires_at, file_size
- Header action: "Generate Pack" (modal with PII/operations toggles)
- Row actions: Download (ready), Expire (destructive + confirmation)
- Empty state: "No review packs yet" + "Generate first pack" CTA
- Filters: status, date range
2. **View Page**: `ViewReviewPack`
- Infolist layout with sections
- Header actions: Download, Regenerate
- Summary display, data freshness table, options used
3. **Widget**: `TenantReviewPackCard`
- Shows latest pack status + metadata
- Actions: Generate, Download (conditional)
**Run**: `vendor/bin/sail artisan test --compact --filter=ReviewPackResource`
---
### Phase 5: Prune Command + Schedule
**Goal:** Retention automation.
1. **Command**: `tenantpilot:review-pack:prune`
- Marks expired, deletes files, optional hard-delete
- `--hard-delete` flag for DB row removal after grace period
2. **Schedule**: Wire in `routes/console.php`
- `daily()` + `withoutOverlapping()`
3. **AlertRule cleanup**: Remove `sla_due` from dropdown options
**Run**: `vendor/bin/sail artisan test --compact --filter="ReviewPackPrune|AlertRule"`
---
### Phase 6: Integration Tests
**Goal:** End-to-end coverage.
1. RBAC enforcement tests (404 for non-members, 403 for insufficient caps)
2. Fingerprint dedupe test (duplicate generation → reuse)
3. Active-run dedupe test (concurrent generation → rejection)
4. Prune test (expired packs → status update + file deletion)
5. Download test (signed URL → file stream with correct headers)
6. Empty state + widget display tests
**Run**: `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/`
---
## File Inventory (New Files)
```
database/migrations/XXXX_create_review_packs_table.php
app/Models/ReviewPack.php
database/factories/ReviewPackFactory.php
app/Support/ReviewPackStatus.php
app/Support/Badges/Mappers/ReviewPackStatusBadge.php
app/Services/ReviewPackService.php
app/Jobs/GenerateReviewPackJob.php
app/Notifications/ReviewPackStatusNotification.php
app/Http/Controllers/ReviewPackDownloadController.php
app/Filament/Resources/ReviewPackResource.php
app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php
app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php
app/Filament/Widgets/TenantReviewPackCard.php
app/Console/Commands/PruneReviewPacksCommand.php
tests/Feature/ReviewPack/ReviewPackGenerationTest.php
tests/Feature/ReviewPack/ReviewPackDownloadTest.php
tests/Feature/ReviewPack/ReviewPackRbacTest.php
tests/Feature/ReviewPack/ReviewPackPruneTest.php
tests/Feature/ReviewPack/ReviewPackResourceTest.php
tests/Feature/ReviewPack/ReviewPackWidgetTest.php
```
## Modified Files
```
app/Support/OperationRunType.php — add ReviewPackGenerate case
app/Support/Auth/Capabilities.php — add REVIEW_PACK_VIEW, REVIEW_PACK_MANAGE
app/Support/Badges/BadgeDomain.php — add ReviewPackStatus case
config/filesystems.php — add exports disk
config/tenantpilot.php — add review_pack section
routes/web.php — add download route
routes/console.php — add prune schedule entry
app/Filament/Resources/AlertRuleResource.php — hide sla_due option
```

View File

@ -0,0 +1,187 @@
# Research: 109 — Tenant Review Pack Export v1 (CSV + ZIP)
**Date**: 2026-02-23
**Branch**: `109-review-pack-export`
---
## 1. OperationRun Integration
### Decision
Add `ReviewPackGenerate` case with value `tenant.review_pack.generate` to `OperationRunType` enum.
### Rationale
- Follows established enum pattern: 14 existing cases in `app/Support/OperationRunType.php`.
- Active-run dedupe is enforced at dispatch time via `scopeActive()` (`whereIn('status', ['queued', 'running'])`), not a DB-level unique index. Same pattern reused.
- Status lifecycle uses existing `OperationRunStatus` enum: `Queued`, `Running`, `Completed`.
- Outcome is set on the run record; failure details go into `context` JSONB (no `reason_code` column — stored as `context['reason_code']` and `context['error_message']`).
### Alternatives Considered
- Adding a DB partial unique index for active-run dedupe → Rejected; existing code uses application-level guard consistently. Adding a DB constraint would be a cross-cutting architectural change.
---
## 2. RBAC Capabilities
### Decision
Add two constants to `app/Support/Auth/Capabilities.php`:
- `REVIEW_PACK_VIEW` = `'review_pack.view'`
- `REVIEW_PACK_MANAGE` = `'review_pack.manage'`
### Rationale
- 35 existing capability constants follow the pattern `domain.action` (e.g., `tenant.view`, `entra_roles.manage`).
- Capabilities are discovered via `Capabilities::all()` using reflection — new constants are auto-discovered.
- No separate enum needed; the constants-class pattern is canonical.
- `REVIEW_PACK_MANAGE` covers generate + expire/delete (per clarification Q2). Confirmation dialog is the safety gate for destructive actions.
### Alternatives Considered
- Separate `REVIEW_PACK_DELETE` capability → Deferred; can be split out later with no migration needed.
---
## 3. StoredReport & Finding Data Access
### Decision
Query existing models directly; no new scopes or accessors needed for v1.
### Rationale
- `StoredReport` has `report_type` constants (`permission_posture`, `entra.admin_roles`), `payload` (array cast), `fingerprint`, `previous_fingerprint`. Query: latest per report_type per tenant.
- `Finding` has `finding_type` constants (`drift`, `permission_posture`, `entra_admin_roles`), `status` (new/acknowledged/resolved), `severity`, `fingerprint`, `evidence_jsonb`. Export scope: `status IN (new, acknowledged)` — resolved findings excluded.
- Both use `DerivesWorkspaceIdFromTenant` trait for workspace_id auto-derivation.
- CSV column mapping is straightforward from model attributes; no transformation layer needed.
### Alternatives Considered
- Adding dedicated export scopes on the models → Over-engineering for v1; simple where clauses suffice. Can be extracted later.
---
## 4. Tenant Hardening Fields
### Decision
Export the following fields to `hardening.json`:
- `rbac_last_checked_at` (datetime)
- `rbac_last_setup_at` (datetime)
- `rbac_canary_results` (array)
- `rbac_last_warnings` (array — includes computed `scope_limited` warning)
- `rbac_scope_mode` (string)
### Rationale
- These are the tenant RBAC hardening/write-safety fields per the Tenant model.
- The `getRbacLastWarningsAttribute` accessor enriches the raw array with `scope_limited` when `rbac_scope_mode === 'scope_group'` — need to use the accessor, not raw DB value.
- `app_client_secret` is encrypted and MUST NOT be exported (FR-008 compliance).
### Alternatives Considered
- Including ProviderConnection health in hardening.json → Deferred to v2; adds complexity and a separate model dependency.
---
## 5. Badge Integration
### Decision
Add `ReviewPackStatus` case to `BadgeDomain` enum and create `ReviewPackStatusBadge` mapper.
### Rationale
- 35 existing badge domains in `app/Support/Badges/BadgeDomain.php`.
- Pattern: enum case → mapper class implementing `BadgeMapper::spec()` returning `BadgeSpec(label, color, ?icon)`.
- Status values: `queued` (warning), `generating` (info), `ready` (success), `failed` (danger), `expired` (gray).
- Colors align with existing palette: `gray`, `info`, `success`, `warning`, `danger`, `primary`.
### Alternatives Considered
- Reusing existing badge domains → Not applicable; review pack status is a new domain with distinct semantics.
---
## 6. Filesystem Disk
### Decision
Add `exports` disk to `config/filesystems.php` using the `local` driver at `storage_path('app/private/exports')`.
### Rationale
- Existing `local` disk pattern: `storage_path('app/private')`, driver `local`, `serve: true`.
- `exports` is private (non-public URL); downloads go through signed route controller.
- Path `storage/app/private/exports/` keeps exports co-located with other private storage.
- In Dokploy deployments, `storage/app/` is typically volume-mounted; no extra volume config needed.
### Alternatives Considered
- Using S3 from the start → Deferred per clarification Q5; local disk for v1, S3 swappable later.
- Using existing `local` disk with a subdirectory → Rejected; dedicated disk gives cleaner config and allows independent retention/backup settings.
---
## 7. Console Schedule
### Decision
Add three new schedule entries to `routes/console.php`:
1. `tenantpilot:review-pack:prune``daily()` + `withoutOverlapping()`
2. `tenantpilot:posture:dispatch`**Does not exist yet.** Must be created as a new Artisan command OR schedule existing job dispatch directly.
3. `tenantpilot:entra-roles:dispatch`**Does not exist yet.** Same as above.
### Rationale
- Currently: Entra admin roles scan is dispatched as a closure via `daily()` + `withoutOverlapping()` (iterates connected tenants). Permission posture generation is event-driven from `ProviderConnectionHealthCheckJob`, not scheduled.
- For FR-015: the spec requires both to be command-based and scheduled. However, creating new Artisan dispatch commands is out of scope for spec 109 if they require significant scanning infrastructure changes.
- **Pragmatic approach**: FR-015 is listed as P3 (lowest priority). If the dispatch commands don't exist yet, wire what exists (the Entra roles closure is already daily) and defer `tenantpilot:posture:dispatch` creation to a separate spec/task. Document this in the plan.
### Alternatives Considered
- Creating full dispatch commands in this spec → Scope creep; the scan orchestration is a separate concern.
---
## 8. AlertRule `sla_due` Cleanup
### Decision
Remove `sla_due` from the AlertRuleResource form dropdown options; keep the `EVENT_SLA_DUE` constant on the model for backward compatibility.
### Rationale
- `sla_due` is defined as a constant on `AlertRule` and appears in the form dropdown at `AlertRuleResource.php` line ~379.
- No producer dispatches `sla_due` events — it's a dead option.
- Removing from the form prevents new rules from selecting it. Existing rules with `sla_due` continue to exist in the DB but won't match any events (harmless).
### Alternatives Considered
- Hard-deleting existing `sla_due` rules via migration → Too aggressive for v1; rules are workspace-owned data.
---
## 9. Download via Signed URL
### Decision
Implement `URL::signedRoute()` with configurable TTL (default: 60 minutes). Download controller validates signature, streams file from `exports` disk.
### Rationale
- No existing signed-URL or download-streaming pattern in the codebase — greenfield.
- Laravel's built-in `URL::signedRoute()` + `hasValidSignature()` middleware is production-proven.
- Download controller is a simple registered route (not Filament); validates signature + checks pack status (must be `ready`, not `expired`).
- TTL configurable via `config('tenantpilot.review_pack.download_url_ttl_minutes')`.
### Alternatives Considered
- Session-authenticated stream → Rejected per clarification Q1; notification links must be self-contained.
---
## 10. Notification
### Decision
Use Laravel's built-in database notification channel. Create `ReviewPackReadyNotification` and `ReviewPackFailedNotification` (or a single `ReviewPackStatusNotification` with context).
### Rationale
- Existing pattern: the app uses Filament's notification system (DB-backed) for user-facing notifications.
- Notification includes: pack status, generated_at, download URL (signed, for `ready`), or failure reason (sanitized, for `failed`).
- Single notification class with conditional rendering based on status is simpler than two separate classes.
### Alternatives Considered
- Two separate notification classes → Slightly cleaner typing but more boilerplate for identical structure. Single class preferred.
---
## 11. ZIP Assembly
### Decision
Use PHP `ZipArchive` with deterministic alphabetical file insertion order. Temporary file written to `sys_get_temp_dir()`, then moved to exports disk.
### Rationale
- `ZipArchive` is available in all PHP 8.4 builds (ext-zip).
- Deterministic order: files added alphabetically by path ensures same content → same ZIP bytes → stable sha256 for fingerprint verification.
- Write to temp first, then `Storage::disk('exports')->put()` — atomic; no partial files on disk if job fails mid-write.
- SHA-256 computed on the final file before persistence.
### Alternatives Considered
- Streaming ZIP (no temp file) → PHP ZipArchive doesn't support true streaming; would need a library like `maennchen/zipstream-php`. Deferred; temp file is fine for expected pack sizes.