plan: 109 review pack export - research, data model, contracts, quickstart
This commit is contained in:
parent
d4cbe7b722
commit
ce8aa1c54b
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||
|
||||
226
specs/109-review-pack-export/contracts/api-contracts.md
Normal file
226
specs/109-review-pack-export/contracts/api-contracts.md
Normal 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]
|
||||
```
|
||||
274
specs/109-review-pack-export/data-model.md
Normal file
274
specs/109-review-pack-export/data-model.md
Normal 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
|
||||
}
|
||||
```
|
||||
131
specs/109-review-pack-export/plan.md
Normal file
131
specs/109-review-pack-export/plan.md
Normal 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).
|
||||
185
specs/109-review-pack-export/quickstart.md
Normal file
185
specs/109-review-pack-export/quickstart.md
Normal 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
|
||||
```
|
||||
187
specs/109-review-pack-export/research.md
Normal file
187
specs/109-review-pack-export/research.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user