From ce8aa1c54bca42b080da0b6457d48d782d9f4f6a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 23 Feb 2026 03:12:33 +0100 Subject: [PATCH] plan: 109 review pack export - research, data model, contracts, quickstart --- .github/agents/copilot-instructions.md | 8 +- .../contracts/api-contracts.md | 226 +++++++++++++++ specs/109-review-pack-export/data-model.md | 274 ++++++++++++++++++ specs/109-review-pack-export/plan.md | 131 +++++++++ specs/109-review-pack-export/quickstart.md | 185 ++++++++++++ specs/109-review-pack-export/research.md | 187 ++++++++++++ 6 files changed, 1008 insertions(+), 3 deletions(-) create mode 100644 specs/109-review-pack-export/contracts/api-contracts.md create mode 100644 specs/109-review-pack-export/data-model.md create mode 100644 specs/109-review-pack-export/plan.md create mode 100644 specs/109-review-pack-export/quickstart.md create mode 100644 specs/109-review-pack-export/research.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 58bb7b6..27632db 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -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] diff --git a/specs/109-review-pack-export/contracts/api-contracts.md b/specs/109-review-pack-export/contracts/api-contracts.md new file mode 100644 index 0000000..c74c6ac --- /dev/null +++ b/specs/109-review-pack-export/contracts/api-contracts.md @@ -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] +``` diff --git a/specs/109-review-pack-export/data-model.md b/specs/109-review-pack-export/data-model.md new file mode 100644 index 0000000..0101713 --- /dev/null +++ b/specs/109-review-pack-export/data-model.md @@ -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 +} +``` diff --git a/specs/109-review-pack-export/plan.md b/specs/109-review-pack-export/plan.md new file mode 100644 index 0000000..7fdd7e0 --- /dev/null +++ b/specs/109-review-pack-export/plan.md @@ -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). diff --git a/specs/109-review-pack-export/quickstart.md b/specs/109-review-pack-export/quickstart.md new file mode 100644 index 0000000..a794656 --- /dev/null +++ b/specs/109-review-pack-export/quickstart.md @@ -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 +``` diff --git a/specs/109-review-pack-export/research.md b/specs/109-review-pack-export/research.md new file mode 100644 index 0000000..9883228 --- /dev/null +++ b/specs/109-review-pack-export/research.md @@ -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.