Fix Review Pack generation UX + notifications #133
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 -->
|
||||
|
||||
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
77
app/Console/Commands/PruneReviewPacksCommand.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PruneReviewPacksCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:review-pack:prune {--hard-delete : Hard-delete expired packs past grace period}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Expire review packs past retention and optionally hard-delete expired rows past grace period';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = $this->expireReadyPacks();
|
||||
$hardDeleted = 0;
|
||||
|
||||
if ($this->option('hard-delete')) {
|
||||
$hardDeleted = $this->hardDeleteExpiredPacks();
|
||||
}
|
||||
|
||||
$this->info("{$expired} pack(s) expired, {$hardDeleted} pack(s) hard-deleted.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition ready packs past retention to expired and delete their files.
|
||||
*/
|
||||
private function expireReadyPacks(): int
|
||||
{
|
||||
$packs = ReviewPack::query()
|
||||
->ready()
|
||||
->pastRetention()
|
||||
->get();
|
||||
|
||||
$disk = Storage::disk('exports');
|
||||
$count = 0;
|
||||
|
||||
foreach ($packs as $pack) {
|
||||
/** @var ReviewPack $pack */
|
||||
if ($pack->file_path && $disk->exists($pack->file_path)) {
|
||||
$disk->delete($pack->file_path);
|
||||
}
|
||||
|
||||
$pack->update(['status' => ReviewPack::STATUS_EXPIRED]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete expired packs that are past the grace period.
|
||||
*/
|
||||
private function hardDeleteExpiredPacks(): int
|
||||
{
|
||||
$graceDays = (int) config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$cutoff = now()->subDays($graceDays);
|
||||
|
||||
return ReviewPack::query()
|
||||
->expired()
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@ -7,10 +7,15 @@
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
class InventoryCluster extends Cluster
|
||||
{
|
||||
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Items';
|
||||
}
|
||||
|
||||
@ -378,7 +378,6 @@ public static function eventTypeOptions(): array
|
||||
return [
|
||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||
];
|
||||
|
||||
@ -31,7 +31,7 @@ protected function getHeaderActions(): array
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->color('primary')
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
$tenant = Tenant::current();
|
||||
|
||||
@ -44,7 +44,7 @@ protected function getHeaderActions(): array
|
||||
Action::make('run_inventory_sync')
|
||||
->label('Run Inventory Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->color('primary')
|
||||
->form([
|
||||
Select::make('policy_types')
|
||||
->label('Policy types')
|
||||
|
||||
352
app/Filament/Resources/ReviewPackResource.php
Normal file
352
app/Filament/Resources/ReviewPackResource.php
Normal file
@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Number;
|
||||
use UnitEnum;
|
||||
|
||||
class ReviewPackResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ReviewPack::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-down';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Review Packs';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($record instanceof ReviewPack) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('file_size')
|
||||
->label('File size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Summary')
|
||||
->schema([
|
||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.permission_posture')
|
||||
->label('Permission posture freshness')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.entra_admin_roles')
|
||||
->label('Entra admin roles freshness')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.findings')
|
||||
->label('Findings freshness')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.data_freshness.hardening')
|
||||
->label('Hardening freshness')
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Options')
|
||||
->schema([
|
||||
TextEntry::make('options.include_pii')
|
||||
->label('Include PII')
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
TextEntry::make('options.include_operations')
|
||||
->label('Include operations')
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation run')
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
||||
: null)
|
||||
->openUrlInNewTab()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('file_size')
|
||||
->label('Size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(collect(ReviewPackStatus::cases())
|
||||
->mapWithKeys(fn (ReviewPackStatus $s): array => [$s->value => ucfirst($s->value)])
|
||||
->all()),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
||||
->url(function (ReviewPack $record): string {
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($record);
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('danger')
|
||||
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will mark the pack as expired and delete the file. This cannot be undone.')
|
||||
->action(function (ReviewPack $record): void {
|
||||
if ($record->file_path && $record->file_disk) {
|
||||
\Illuminate\Support\Facades\Storage::disk($record->file_disk)->delete($record->file_path);
|
||||
}
|
||||
|
||||
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Review pack expired')
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
])
|
||||
->emptyStateHeading('No review packs yet')
|
||||
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||
->emptyStateActions([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('generate_first')
|
||||
->label('Generate first pack')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(function (array $data): void {
|
||||
static::executeGeneration($data);
|
||||
})
|
||||
->form([
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListReviewPacks::route('/'),
|
||||
'view' => Pages\ViewReviewPack::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function executeGeneration(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRun($tenant)) {
|
||||
Notification::make()->warning()->title('A review pack is already being generated.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$options = [
|
||||
'include_pii' => (bool) ($data['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Review pack already available')
|
||||
->body('A matching review pack is already ready. No new run was started.')
|
||||
->actions([
|
||||
Actions\Action::make('view_pack')
|
||||
->label('View pack')
|
||||
->url(static::getUrl('view', ['record' => $reviewPack], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ListReviewPacks extends ListRecords
|
||||
{
|
||||
protected static string $resource = ReviewPackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('generate_pack')
|
||||
->label('Generate Pack')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(function (array $data): void {
|
||||
ReviewPackResource::executeGeneration($data);
|
||||
})
|
||||
->form([
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
]),
|
||||
])
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ViewReviewPack extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ReviewPackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||
->openUrlInNewTab(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||
->action(function (array $data): void {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
|
||||
$options = array_merge($record->options ?? [], [
|
||||
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||
]);
|
||||
|
||||
ReviewPackResource::executeGeneration($options);
|
||||
})
|
||||
->form(function (): array {
|
||||
/** @var ReviewPack $record */
|
||||
$record = $this->record;
|
||||
$currentOptions = $record->options ?? [];
|
||||
|
||||
return [
|
||||
Section::make('Pack options')
|
||||
->schema([
|
||||
Toggle::make('include_pii')
|
||||
->label('Include PII')
|
||||
->helperText('Include personally identifiable information in the export.')
|
||||
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||
Toggle::make('include_operations')
|
||||
->label('Include operations')
|
||||
->helperText('Include recent operation history in the export.')
|
||||
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||
]),
|
||||
];
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
158
app/Filament/Widgets/Tenant/TenantReviewPackCard.php
Normal file
158
app/Filament/Widgets/Tenant/TenantReviewPackCard.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantReviewPackCard extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.tenant-review-pack-card';
|
||||
|
||||
public ?Tenant $record = null;
|
||||
|
||||
private function resolveTenant(): ?Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return $this->record instanceof Tenant ? $this->record : null;
|
||||
}
|
||||
|
||||
public function generatePack(bool $includePii = true, bool $includeOperations = true): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRun($tenant)) {
|
||||
Notification::make()
|
||||
->title('Generation already in progress')
|
||||
->body('A review pack is currently being generated for this tenant.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service->generate($tenant, $user, [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Review pack generation started')
|
||||
->body('The pack will be generated in the background. You will be notified when it is ready.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = $this->resolveTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $this->emptyState();
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $latestPack instanceof ReviewPack) {
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'pack' => null,
|
||||
'statusEnum' => null,
|
||||
'canView' => $canView,
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$statusEnum = ReviewPackStatus::tryFrom((string) $latestPack->status);
|
||||
|
||||
$downloadUrl = null;
|
||||
if ($statusEnum === ReviewPackStatus::Ready && $canView) {
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||
}
|
||||
|
||||
$failedReason = null;
|
||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
|
||||
}
|
||||
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'pack' => $latestPack,
|
||||
'statusEnum' => $statusEnum,
|
||||
'canView' => $canView,
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function emptyState(): array
|
||||
{
|
||||
return [
|
||||
'tenant' => null,
|
||||
'pack' => null,
|
||||
'statusEnum' => null,
|
||||
'canView' => false,
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/ReviewPackDownloadController.php
Normal file
43
app/Http/Controllers/ReviewPackDownloadController.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ReviewPackDownloadController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
||||
{
|
||||
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($reviewPack->expires_at && $reviewPack->expires_at->isPast()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($reviewPack->file_disk ?? 'exports');
|
||||
|
||||
if (! $disk->exists($reviewPack->file_path)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$tenant = $reviewPack->tenant;
|
||||
$filename = sprintf(
|
||||
'review-pack-%s-%s.zip',
|
||||
$tenant?->external_id ?? 'unknown',
|
||||
$reviewPack->generated_at?->format('Y-m-d') ?? now()->format('Y-m-d'),
|
||||
);
|
||||
|
||||
return $disk->download($reviewPack->file_path, $filename, [
|
||||
'X-Review-Pack-SHA256' => $reviewPack->sha256 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
392
app/Jobs/GenerateReviewPackJob.php
Normal file
392
app/Jobs/GenerateReviewPackJob.php
Normal file
@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
class GenerateReviewPackJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $reviewPackId,
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
Log::warning('GenerateReviewPackJob: missing records', [
|
||||
'review_pack_id' => $this->reviewPackId,
|
||||
'operation_run_id' => $this->operationRunId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $reviewPack->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark running via OperationRunService (auto-sets started_at)
|
||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||
{
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
// 1. Collect StoredReports
|
||||
$storedReports = StoredReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->get()
|
||||
->keyBy('report_type');
|
||||
|
||||
// 2. Collect Findings (open + acknowledged)
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->orderBy('severity')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// 3. Collect tenant hardening fields
|
||||
$hardening = [
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||
];
|
||||
|
||||
// 4. Collect recent OperationRuns (30 days)
|
||||
$recentOperations = $includeOperations
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// 5. Data freshness
|
||||
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
||||
|
||||
// 6. Build file map
|
||||
$fileMap = $this->buildFileMap(
|
||||
storedReports: $storedReports,
|
||||
findings: $findings,
|
||||
hardening: $hardening,
|
||||
recentOperations: $recentOperations,
|
||||
tenant: $tenant,
|
||||
dataFreshness: $dataFreshness,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
|
||||
// 7. Assemble ZIP
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
|
||||
try {
|
||||
$this->assembleZip($tempFile, $fileMap);
|
||||
|
||||
// 8. Compute SHA-256
|
||||
$sha256 = hash_file('sha256', $tempFile);
|
||||
$fileSize = filesize($tempFile);
|
||||
|
||||
// 9. Store on exports disk
|
||||
$filePath = sprintf(
|
||||
'review-packs/%s/%s.zip',
|
||||
$tenant->external_id,
|
||||
now()->format('Y-m-d-His'),
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Compute fingerprint
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options);
|
||||
|
||||
// 11. Compute summary
|
||||
$summary = [
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'data_freshness' => $dataFreshness,
|
||||
];
|
||||
|
||||
// 12. Update ReviewPack
|
||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => $fingerprint,
|
||||
'sha256' => $sha256,
|
||||
'file_size' => $fileSize,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
// 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification)
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $summary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||
* @return array<string, ?string>
|
||||
*/
|
||||
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the file map for the ZIP contents.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildFileMap(
|
||||
$storedReports,
|
||||
$findings,
|
||||
array $hardening,
|
||||
$recentOperations,
|
||||
Tenant $tenant,
|
||||
array $dataFreshness,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
): array {
|
||||
$files = [];
|
||||
|
||||
// findings.csv
|
||||
$files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii);
|
||||
|
||||
// hardening.json
|
||||
$files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
// metadata.json
|
||||
$files['metadata.json'] = json_encode([
|
||||
'version' => '1.0',
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'options' => [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
// operations.csv
|
||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||
|
||||
// reports/entra_admin_roles.json
|
||||
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
||||
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
// reports/permission_posture.json
|
||||
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||
$files['reports/permission_posture.json'] = json_encode(
|
||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
// summary.json
|
||||
$files['summary.json'] = json_encode([
|
||||
'data_freshness' => $dataFreshness,
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build findings CSV content.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||
*/
|
||||
private function buildFindingsCsv($findings, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
fputcsv($handle, [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$content = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build operations CSV content.
|
||||
*/
|
||||
private function buildOperationsCsv($operations, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
fputcsv($handle, [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$content = stream_get_contents($handle);
|
||||
fclose($handle);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from a report payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactReportPayload(array $payload, bool $includePii): array
|
||||
{
|
||||
if ($includePii) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
return $this->redactArrayPii($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively redact PII fields from an array.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactArrayPii(array $data): array
|
||||
{
|
||||
$piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail'];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($key) && in_array($key, $piiKeys, true)) {
|
||||
$data[$key] = '[REDACTED]';
|
||||
} elseif (is_array($value)) {
|
||||
$data[$key] = $this->redactArrayPii($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a ZIP file from a file map.
|
||||
*
|
||||
* @param array<string, string> $fileMap
|
||||
*/
|
||||
private function assembleZip(string $tempFile, array $fileMap): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
|
||||
if ($result !== true) {
|
||||
throw new \RuntimeException("Failed to create ZIP archive: error code {$result}");
|
||||
}
|
||||
|
||||
// Add files in alphabetical order for deterministic output
|
||||
ksort($fileMap);
|
||||
|
||||
foreach ($fileMap as $filename => $content) {
|
||||
$zip->addFromString($filename, $content);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
133
app/Models/ReviewPack.php
Normal file
133
app/Models/ReviewPack.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReviewPack extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
public const string STATUS_QUEUED = 'queued';
|
||||
|
||||
public const string STATUS_GENERATING = 'generating';
|
||||
|
||||
public const string STATUS_READY = 'ready';
|
||||
|
||||
public const string STATUS_FAILED = 'failed';
|
||||
|
||||
public const string STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'summary' => 'array',
|
||||
'options' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<OperationRun, $this>
|
||||
*/
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeReady(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_READY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopePastRetention(Builder $query): Builder
|
||||
{
|
||||
return $query->where('expires_at', '<', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeLatestReady(Builder $query): Builder
|
||||
{
|
||||
return $query->ready()->latest('generated_at');
|
||||
}
|
||||
|
||||
public function isReady(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_READY;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED;
|
||||
}
|
||||
|
||||
public function getStatusEnum(): ReviewPackStatus
|
||||
{
|
||||
return ReviewPackStatus::from($this->status);
|
||||
}
|
||||
}
|
||||
97
app/Policies/ReviewPackPolicy.php
Normal file
97
app/Policies/ReviewPackPolicy.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class ReviewPackPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, ReviewPack $reviewPack): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $reviewPack->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, ReviewPack $reviewPack): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $reviewPack->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE);
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,9 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
Capabilities::ENTRA_ROLES_MANAGE,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
@ -65,6 +68,9 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
Capabilities::ENTRA_ROLES_MANAGE,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
@ -84,6 +90,8 @@ class RoleCapabilityMap
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
@ -97,6 +105,8 @@ class RoleCapabilityMap
|
||||
Capabilities::AUDIT_VIEW,
|
||||
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
153
app/Services/ReviewPackService.php
Normal file
153
app/Services/ReviewPackService.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class ReviewPackService
|
||||
{
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create an OperationRun + ReviewPack and dispatch the generation job.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
||||
{
|
||||
$options = $this->normalizeOptions($options);
|
||||
$fingerprint = $this->computeFingerprint($tenant, $options);
|
||||
|
||||
$existing = $this->findExistingPack($tenant, $fingerprint);
|
||||
if ($existing instanceof ReviewPack) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::ReviewPackGenerate->value,
|
||||
inputs: [
|
||||
'include_pii' => $options['include_pii'],
|
||||
'include_operations' => $options['include_operations'],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$reviewPack = ReviewPack::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [],
|
||||
]);
|
||||
|
||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
||||
GenerateReviewPackJob::dispatch(
|
||||
reviewPackId: (int) $reviewPack->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic fingerprint for deduplication.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function computeFingerprint(Tenant $tenant, array $options): string
|
||||
{
|
||||
$reportFingerprints = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->orderBy('report_type')
|
||||
->pluck('fingerprint')
|
||||
->toArray();
|
||||
|
||||
$maxFindingDate = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_ACKNOWLEDGED])
|
||||
->max('updated_at');
|
||||
|
||||
$data = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
'report_fingerprints' => $reportFingerprints,
|
||||
'max_finding_date' => $maxFindingDate,
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signed download URL for a review pack.
|
||||
*/
|
||||
public function generateDownloadUrl(ReviewPack $pack): string
|
||||
{
|
||||
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
||||
|
||||
return URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addMinutes($ttlMinutes),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing ready, non-expired pack with the same fingerprint.
|
||||
*/
|
||||
public function findExistingPack(Tenant $tenant, string $fingerprint): ?ReviewPack
|
||||
{
|
||||
return ReviewPack::query()
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->ready()
|
||||
->where('fingerprint', $fingerprint)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a generation run is currently active for this tenant.
|
||||
*/
|
||||
public function checkActiveRun(Tenant $tenant): bool
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||
->active()
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{include_pii: bool, include_operations: bool}
|
||||
*/
|
||||
private function normalizeOptions(array $options): array
|
||||
{
|
||||
return [
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? config('tenantpilot.review_pack.include_pii_default', true)),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -109,6 +109,11 @@ class Capabilities
|
||||
|
||||
public const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
|
||||
|
||||
// Review packs
|
||||
public const REVIEW_PACK_VIEW = 'review_pack.view';
|
||||
|
||||
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
|
||||
|
||||
/**
|
||||
* Get all capability constants
|
||||
*
|
||||
|
||||
@ -41,6 +41,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -33,4 +33,5 @@ enum BadgeDomain: string
|
||||
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
}
|
||||
|
||||
27
app/Support/Badges/Domains/ReviewPackStatusBadge.php
Normal file
27
app/Support/Badges/Domains/ReviewPackStatusBadge.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\ReviewPackStatus;
|
||||
|
||||
final class ReviewPackStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
ReviewPackStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
ReviewPackStatus::Generating->value => new BadgeSpec('Generating', 'info', 'heroicon-m-arrow-path'),
|
||||
ReviewPackStatus::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
ReviewPackStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
ReviewPackStatus::Expired->value => new BadgeSpec('Expired', 'gray', 'heroicon-m-archive-box'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,7 @@ public static function labels(): array
|
||||
'baseline_compare' => 'Baseline compare',
|
||||
'permission_posture_check' => 'Permission posture check',
|
||||
'entra.admin_roles.scan' => 'Entra admin roles scan',
|
||||
'tenant.review_pack.generate' => 'Review pack generation',
|
||||
'rbac.health_check' => 'RBAC health check',
|
||||
];
|
||||
}
|
||||
@ -83,6 +84,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'baseline_compare' => 120,
|
||||
'permission_posture_check' => 30,
|
||||
'entra.admin_roles.scan' => 60,
|
||||
'tenant.review_pack.generate' => 60,
|
||||
'rbac.health_check' => 30,
|
||||
default => null,
|
||||
};
|
||||
|
||||
@ -17,6 +17,7 @@ enum OperationRunType: string
|
||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
case RbacHealthCheck = 'rbac.health_check';
|
||||
|
||||
public static function values(): array
|
||||
|
||||
@ -36,6 +36,9 @@ public static function all(): array
|
||||
'report_created',
|
||||
'report_deduped',
|
||||
'alert_events_produced',
|
||||
'finding_count',
|
||||
'report_count',
|
||||
'operation_count',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
14
app/Support/ReviewPackStatus.php
Normal file
14
app/Support/ReviewPackStatus.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
enum ReviewPackStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Generating = 'generating';
|
||||
case Ready = 'ready';
|
||||
case Failed = 'failed';
|
||||
case Expired = 'expired';
|
||||
}
|
||||
@ -60,6 +60,13 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'exports' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private/exports'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -358,6 +358,14 @@
|
||||
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
||||
],
|
||||
|
||||
'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),
|
||||
],
|
||||
|
||||
'hardening' => [
|
||||
'intune_write_gate' => [
|
||||
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),
|
||||
|
||||
133
database/factories/ReviewPackFactory.php
Normal file
133
database/factories/ReviewPackFactory.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ReviewPack>
|
||||
*/
|
||||
class ReviewPackFactory extends Factory
|
||||
{
|
||||
protected $model = ReviewPack::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'initiated_by_user_id' => User::factory(),
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'previous_fingerprint' => null,
|
||||
'summary' => [
|
||||
'finding_count' => fake()->numberBetween(0, 100),
|
||||
'report_count' => fake()->numberBetween(0, 10),
|
||||
],
|
||||
'options' => [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => fn () => 'review-packs/'.fake()->uuid().'.zip',
|
||||
'file_size' => fake()->numberBetween(1024, 1048576),
|
||||
'sha256' => fake()->sha256(),
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
];
|
||||
}
|
||||
|
||||
public function queued(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function generating(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Generating->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ready(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => 'review-packs/'.fake()->uuid().'.zip',
|
||||
'file_size' => fake()->numberBetween(1024, 1048576),
|
||||
'sha256' => fake()->sha256(),
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Failed->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function expired(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Expired->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => now()->subDays(91),
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_packs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('status')->default('queued');
|
||||
$table->string('fingerprint', 64)->nullable();
|
||||
$table->string('previous_fingerprint', 64)->nullable();
|
||||
$table->jsonb('summary')->default('{}');
|
||||
$table->jsonb('options')->default('{}');
|
||||
$table->string('file_disk')->nullable();
|
||||
$table->string('file_path')->nullable();
|
||||
$table->bigInteger('file_size')->nullable();
|
||||
$table->string('sha256', 64)->nullable();
|
||||
$table->timestampTz('generated_at')->nullable();
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'tenant_id', 'generated_at']);
|
||||
$table->index(['status', 'expires_at']);
|
||||
});
|
||||
|
||||
DB::statement('
|
||||
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\')
|
||||
');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_packs');
|
||||
}
|
||||
};
|
||||
@ -6,66 +6,93 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const Livewire = window.Livewire;
|
||||
const applyShim = () => {
|
||||
const Livewire = window.Livewire;
|
||||
|
||||
if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Livewire.__tenantpilotInterceptMessageShimApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const original = Livewire.interceptMessage.bind(Livewire);
|
||||
|
||||
Livewire.interceptMessage = (handler) => {
|
||||
if (typeof handler !== 'function') {
|
||||
return original(handler);
|
||||
if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return original((context) => {
|
||||
if (!context || typeof context !== 'object') {
|
||||
return handler(context);
|
||||
if (Livewire.__tenantpilotInterceptMessageShimApplied) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const original = Livewire.interceptMessage.bind(Livewire);
|
||||
|
||||
Livewire.interceptMessage = (handler) => {
|
||||
if (typeof handler !== 'function') {
|
||||
return original(handler);
|
||||
}
|
||||
|
||||
const originalOnFinish = context.onFinish;
|
||||
const originalOnSuccess = context.onSuccess;
|
||||
|
||||
if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
const finishCallbacks = [];
|
||||
|
||||
const onFinish = (callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
finishCallbacks.push(callback);
|
||||
return original((context) => {
|
||||
if (!context || typeof context !== 'object') {
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
return originalOnFinish(callback);
|
||||
};
|
||||
const originalOnFinish = context.onFinish;
|
||||
const originalOnSuccess = context.onSuccess;
|
||||
|
||||
const onSuccess = (callback) => {
|
||||
return originalOnSuccess((...args) => {
|
||||
// Ensure any registered finish callbacks are run before success callbacks.
|
||||
// We don't swallow errors; we just stabilize ordering.
|
||||
for (const finishCallback of finishCallbacks) {
|
||||
finishCallback(...args);
|
||||
}
|
||||
if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
const finishCallbacks = [];
|
||||
|
||||
const onFinish = (callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
return callback(...args);
|
||||
finishCallbacks.push(callback);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return handler({
|
||||
...context,
|
||||
onFinish,
|
||||
onSuccess,
|
||||
return originalOnFinish(callback);
|
||||
};
|
||||
|
||||
const onSuccess = (callback) => {
|
||||
return originalOnSuccess((...args) => {
|
||||
// Ensure any registered finish callbacks are run before success callbacks.
|
||||
// We don't swallow errors; we just stabilize ordering.
|
||||
for (const finishCallback of finishCallbacks) {
|
||||
finishCallback(...args);
|
||||
}
|
||||
|
||||
if (typeof callback === 'function') {
|
||||
return callback(...args);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return handler({
|
||||
...context,
|
||||
onFinish,
|
||||
onSuccess,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Livewire.__tenantpilotInterceptMessageShimApplied = true;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Livewire.__tenantpilotInterceptMessageShimApplied = true;
|
||||
if (applyShim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Livewire may not be initialized yet when this script runs (depending on
|
||||
// script tag order). Try again on `livewire:init` and with a short fallback poll.
|
||||
const onInit = () => {
|
||||
applyShim();
|
||||
};
|
||||
|
||||
window.addEventListener('livewire:init', onInit, { once: true });
|
||||
document.addEventListener('livewire:init', onInit, { once: true });
|
||||
|
||||
let tries = 0;
|
||||
const maxTries = 50;
|
||||
const timer = setInterval(() => {
|
||||
tries += 1;
|
||||
|
||||
if (applyShim() || tries >= maxTries) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 100);
|
||||
})();
|
||||
|
||||
@ -0,0 +1,164 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\ReviewPackStatus;
|
||||
|
||||
/** @var ?\App\Models\Tenant $tenant */
|
||||
/** @var ?\App\Models\ReviewPack $pack */
|
||||
/** @var ?ReviewPackStatus $statusEnum */
|
||||
/** @var bool $canView */
|
||||
/** @var bool $canManage */
|
||||
/** @var ?string $downloadUrl */
|
||||
/** @var ?string $failedReason */
|
||||
|
||||
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
|
||||
@endphp
|
||||
|
||||
<x-filament::section heading="Review Pack">
|
||||
@if (! $pack)
|
||||
{{-- State 1: No pack --}}
|
||||
<div class="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<x-heroicon-o-document-arrow-down class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No review pack generated yet.
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Generate pack
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
|
||||
{{-- State 2: Queued / Generating --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-filament::loading-indicator class="h-4 w-4" />
|
||||
Generation in progress…
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Ready)
|
||||
{{-- State 3: Ready --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Generated</dt>
|
||||
<dd>{{ $pack->generated_at?->format('M j, Y H:i') ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Expires</dt>
|
||||
<dd>{{ $pack->expires_at?->format('M j, Y') ?? '—' }}</dd>
|
||||
|
||||
<dt class="text-gray-500 dark:text-gray-400">Size</dt>
|
||||
<dd>{{ $pack->file_size ? Number::fileSize($pack->file_size) : '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($canView && $downloadUrl)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
tag="a"
|
||||
:href="$downloadUrl"
|
||||
target="_blank"
|
||||
icon="heroicon-o-arrow-down-tray"
|
||||
>
|
||||
Download
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="gray"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Generate new
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Failed)
|
||||
{{-- State 4: Failed --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($failedReason)
|
||||
<div class="text-sm text-danger-600 dark:text-danger-400">
|
||||
{{ $failedReason }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $pack->updated_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Retry
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($statusEnum === ReviewPackStatus::Expired)
|
||||
{{-- State 5: Expired --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="$badgeSpec?->color"
|
||||
:icon="$badgeSpec?->icon"
|
||||
>
|
||||
{{ $badgeSpec?->label ?? '—' }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Expired {{ $pack->expires_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
wire:click="generatePack"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
Generate new
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@ -34,6 +34,11 @@
|
||||
->name('stored-reports:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('tenantpilot:review-pack:prune')
|
||||
->daily()
|
||||
->name('tenantpilot:review-pack:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::call(function (): void {
|
||||
$tenants = Tenant::query()
|
||||
->whereHas('providerConnections', function ($q): void {
|
||||
@ -52,3 +57,6 @@
|
||||
->daily()
|
||||
->name('entra-admin-roles:scan')
|
||||
->withoutOverlapping();
|
||||
|
||||
// TODO: Add tenantpilot:posture:dispatch schedule entry once the command
|
||||
// infrastructure exists (FR-015 deferred — see specs/109 research.md §7).
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\ClearTenantContextController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Http\Controllers\ReviewPackDownloadController;
|
||||
use App\Http\Controllers\SelectTenantController;
|
||||
use App\Http\Controllers\SwitchWorkspaceController;
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
@ -206,6 +207,10 @@
|
||||
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
||||
->name('admin.workspace.managed-tenants.index');
|
||||
|
||||
Route::middleware(['signed'])
|
||||
->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class)
|
||||
->name('admin.review-packs.download');
|
||||
|
||||
if (app()->runningUnitTests()) {
|
||||
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
||||
->get('/admin/_test/workspace-context', function (Request $request) {
|
||||
|
||||
@ -184,7 +184,7 @@ ### Functional Requirements
|
||||
### Canonical allowed summary keys (single source of truth)
|
||||
|
||||
The following keys are the ONLY allowed summary keys for Ops-UX rendering:
|
||||
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants, high, medium, low, findings_created, findings_resolved, findings_reopened, findings_unchanged, errors_recorded, posture_score, report_created, report_deduped, alert_events_produced`
|
||||
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants, high, medium, low, findings_created, findings_resolved, findings_reopened, findings_unchanged, errors_recorded, posture_score, report_created, report_deduped, alert_events_produced, finding_count, report_count, operation_count`
|
||||
|
||||
All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).
|
||||
|
||||
|
||||
34
specs/109-review-pack-export/checklists/requirements.md
Normal file
34
specs/109-review-pack-export/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Tenant Review Pack Export v1 (CSV + ZIP)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-23
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All items pass. Spec is ready for `/speckit.plan`.
|
||||
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'])`). The existing DB partial unique index on `operation_runs` (`operation_runs_active_unique`) serves as a safety backstop. 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.
|
||||
231
specs/109-review-pack-export/spec.md
Normal file
231
specs/109-review-pack-export/spec.md
Normal file
@ -0,0 +1,231 @@
|
||||
# Feature Specification: Tenant Review Pack Export v1 (CSV + ZIP)
|
||||
|
||||
**Feature Branch**: `109-review-pack-export`
|
||||
**Created**: 2026-02-23
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Tenant Review Pack Export v1 (CSV + ZIP) — exportierbares Audit-Artefakt (ZIP mit CSV + JSON) fuer MSP/Enterprise Tenant Reviews, DB-only UX, Fingerprint-Dedupe, RBAC-gesicherter Download, Retention-Pruning."
|
||||
|
||||
---
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant-context (`/admin/t/{tenant}/...`)
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/review-packs` — ReviewPackResource list
|
||||
- `/admin/t/{tenant}/review-packs/{id}` — View detail
|
||||
- Download route (signed temporary URL via `URL::signedRoute()`, private storage; URL expires after configurable TTL)
|
||||
- Tenant dashboard card (embedded widget/section)
|
||||
- **Data Ownership**: workspace-owned + tenant-scoped (`review_packs` links `workspace_id` + `tenant_id`); binary files on private `exports` disk.
|
||||
- **RBAC**:
|
||||
- Non-member → 404 (deny-as-not-found; workspace/tenant isolation enforced before any response).
|
||||
- `REVIEW_PACK_VIEW` → list + download.
|
||||
- `REVIEW_PACK_MANAGE` → generate + download.
|
||||
- Missing capability but workspace member → 403.
|
||||
- All capability checks go through the canonical capability registry (no raw strings in feature code).
|
||||
|
||||
For canonical-view compliance:
|
||||
- **Default filter when tenant-context is active**: list prefiltered to current tenant; `workspace_id` + `tenant_id` always scoped server-side.
|
||||
- **Cross-tenant leakage prevention**: every query builder scope asserts `workspace_id` = authed workspace + `tenant_id` = routed tenant; policy enforces both before any data is returned.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Generate and Download Review Pack (Priority: P1)
|
||||
|
||||
An MSP engineer wants to produce an auditable deliverable for a client tenant review. They open the Tenant Dashboard, see the "Tenant Review Pack" card, and click "Generate pack". The system creates a background job that collects the latest evidence (stored reports, findings, hardening status, recent operations), assembles a ZIP, and stores it privately. Once ready, the engineer receives a DB notification with a download link. They download the ZIP and find `summary.json`, `findings.csv`, `operations.csv`, `hardening.json`, `reports/permission_posture.json`, `reports/entra_admin_roles.json`, and `metadata.json`.
|
||||
|
||||
**Why this priority**: This is the core deliverable. Without it the feature has no value.
|
||||
|
||||
**Independent Test**: Create a tenant with stored reports + findings, trigger generation as a `REVIEW_PACK_MANAGE` user, assert OperationRun created, `review_packs` row transitions to `ready`, ZIP file exists on disk containing the expected file set.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant with at least one `permission_posture` and one `entra.admin_roles` stored report and open findings, **When** a `REVIEW_PACK_MANAGE` user triggers "Generate pack", **Then** an `OperationRun` of type `tenant.review_pack.generate` is created and a job is enqueued; once processed the `review_packs` row has `status = ready`, a file exists at `file_path` on the `exports` disk, `sha256` and `file_size` are populated, and a DB notification is sent to the initiator.
|
||||
2. **Given** the ZIP is generated, **When** the engineer inspects its contents, **Then** the archive contains exactly: `summary.json`, `findings.csv`, `operations.csv`, `hardening.json`, `reports/permission_posture.json`, `reports/entra_admin_roles.json`, `metadata.json`.
|
||||
3. **Given** a ready pack, **When** a `REVIEW_PACK_VIEW` user clicks "Download latest", **Then** the file is returned with correct content-type and disposition headers and the content matches the stored `sha256`.
|
||||
4. **Given** the option `include_pii = false`, **When** the pack is generated, **Then** `principal.display_name` values are replaced with a redacted placeholder in all exported files; UUIDs and object types are retained.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Fingerprint Dedupe (Priority: P2)
|
||||
|
||||
An MSP engineer accidentally clicks "Generate pack" twice in quick succession. The system must not produce two identical packs. If a `ready` pack with the same fingerprint already exists and is not expired, the second request returns the existing pack without creating a new artifact.
|
||||
|
||||
**Why this priority**: Prevents storage waste and ensures idempotency — critical for scheduled and automated workflows.
|
||||
|
||||
**Independent Test**: Trigger pack generation twice with identical inputs; assert only one `review_packs` row with `status = ready` exists and only one file is on disk.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a `ready` ReviewPack with fingerprint F exists and has not expired, **When** a new generation request arrives with the same inputs (same tenant, same options, same report fingerprints), **Then** no new `review_packs` row is created and the existing pack is returned.
|
||||
2. **Given** an active in-progress `OperationRun` of type `tenant.review_pack.generate` for the same tenant, **When** a concurrent generate request arrives, **Then** the second request is rejected with a clear user-facing message ("generation already in progress").
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — RBAC Enforcement (Priority: P2)
|
||||
|
||||
Access to review packs is strictly scoped: non-members cannot discover pack existence; view-only members can list and download but not generate; manage members can both generate and download; destructive actions (expire/delete) require a higher role and explicit confirmation.
|
||||
|
||||
**Why this priority**: Security and compliance requirement; prevents cross-tenant data leakage.
|
||||
|
||||
**Independent Test**: Test each role (no membership, view, manage) against each action (list, view, download, generate, expire) and assert correct HTTP status or Filament authorization result.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user with no workspace membership, **When** they attempt to list or download a tenant's review packs, **Then** they receive a 404 (deny-as-not-found).
|
||||
2. **Given** a workspace member with `REVIEW_PACK_VIEW` but not `REVIEW_PACK_MANAGE`, **When** they view the list and download a ready pack, **Then** both actions succeed; **When** they attempt to trigger generation, **Then** they receive a 403.
|
||||
3. **Given** a workspace member with `REVIEW_PACK_MANAGE`, **When** they trigger generation and download the result, **Then** both actions succeed.
|
||||
4. **Given** an Owner/Manager invoking expire on a pack, **When** they invoke the destructive action, **Then** a confirmation dialog is shown before execution.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Retention & Prune (Priority: P3)
|
||||
|
||||
Packs older than the configured retention period (default 90 days) are automatically expired and their storage files deleted by a nightly scheduled command.
|
||||
|
||||
**Why this priority**: Storage hygiene and compliance; required before production.
|
||||
|
||||
**Independent Test**: Create a ReviewPack with `expires_at` in the past, run the prune command, assert `status = expired` and storage file deleted.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a `ready` ReviewPack whose `expires_at` is in the past, **When** the nightly prune command runs, **Then** the pack status is updated to `expired` and its file is deleted from the `exports` disk.
|
||||
2. **Given** a `ready` ReviewPack whose `expires_at` is in the future, **When** the prune command runs, **Then** the pack and its file are unaffected.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Scheduled Scan Wiring (Priority: P3)
|
||||
|
||||
Daily permission-posture and Entra-admin-roles scans are wired in the Laravel scheduler so that review pack generation normally consumes pre-cached stored reports rather than triggering on-demand heavy scans.
|
||||
|
||||
**Why this priority**: Operational readiness; ensures review packs use fresh data without blocking the generate request.
|
||||
|
||||
**Independent Test**: Assert the Laravel console schedule contains both dispatch commands with at least daily frequency.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the application scheduler, **When** the schedule is inspected programmatically, **Then** `tenantpilot:posture:dispatch` and `tenantpilot:entra-roles:dispatch` both appear with at least daily frequency.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when no stored reports exist for a tenant when generation is triggered? The job produces a pack with empty report sections and documents this in `summary.json`; the pack is marked `ready` (not `failed`).
|
||||
- What happens when `Storage::disk('exports')` write fails mid-generation? The `OperationRun` is marked `failed` with reason_code `review_pack.storage_failed`; no `ready` review_pack row is created; the initiator receives a failure notification.
|
||||
- What happens if the pack expires before the user clicks the download link from the notification? The download endpoint returns a user-friendly expired/not-found error; no secret information is revealed.
|
||||
- What happens if `include_pii` is not specified in the generation request? Defaults to `true` (include display names); workspace-level override is out of scope v1.
|
||||
- How does the system handle tenants with very large finding sets? All open + recently-seen findings within the 30-day window are exported; query uses chunked iteration to avoid memory exhaustion; no pagination in CSV v1.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment:** This feature introduces:
|
||||
- A new `OperationRun` type (`tenant.review_pack.generate`) with active-run and content-dedupe. Observability, identity, and visibility fully covered.
|
||||
- Write behavior (file write to private storage, DB row creation) — no Graph calls during the generate request; all evidence sourced from pre-existing `stored_reports`.
|
||||
- Two new RBAC capabilities (`REVIEW_PACK_VIEW`, `REVIEW_PACK_MANAGE`) registered in the canonical capability registry; no raw strings in feature code.
|
||||
- Filament Resource + Tenant Dashboard card with UI Action Matrix and UX-001 compliance (described below).
|
||||
- Status badge on `review_packs.status` — centralized badge mapping per BADGE-001.
|
||||
- Destructive actions (expire) enforce `->requiresConfirmation()`.
|
||||
- Audit exemption (Expire action): Expire mutates the pack row (`status → expired`, `updated_at` updated) and deletes the storage file. The pack row itself is the audit trail (status change + timestamp + `initiated_by_user_id`). No separate `AuditLog` entry required — the mutation is observable via the persisted status field.
|
||||
- Authorization plane: tenant-context (`/admin/t/{tenant}/...`); 404 for non-members, 403 for members lacking capability.
|
||||
- All generation and expiry operations tracked via `operation_runs`; no observable mutation skips the OperationRun trail.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** No OIDC/SAML handshakes. Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** `review_packs.status` values (`queued`, `generating`, `ready`, `failed`, `expired`) must be registered in the shared badge registry with centralized color/label mappings. No ad-hoc inline color assignment. Tests assert each status renders the correct badge.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** UI Action Matrix provided below. Action Surface Contract satisfied.
|
||||
|
||||
**Constitution alignment (UX-001):**
|
||||
- ReviewPackResource List: search + sort on `generated_at`, `status`, `tenant`; status + date-range filters; meaningful empty state with specific title, explanation, and exactly one primary CTA.
|
||||
- View page uses Infolist (not a disabled edit form).
|
||||
- "Generate" is a modal Header Action (not a dedicated Create page); exemption: generation is a background-job trigger, not a data-entry form — documented here.
|
||||
- Status badges use BADGE-001.
|
||||
- All form fields in the options modal placed inside Sections/Cards; no naked inputs.
|
||||
|
||||
---
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a `review_packs` table with columns: `id`, `workspace_id` (FK NOT NULL), `tenant_id` (FK NOT NULL), `operation_run_id` (FK nullable), `initiated_by_user_id` (FK → users, nullable), `generated_at`, `fingerprint`, `previous_fingerprint` (nullable), `status` (enum: queued/generating/ready/failed/expired), `summary` (jsonb), `options` (jsonb), `file_disk`, `file_path`, `file_size` (bigint), `sha256`, `expires_at`, timestamps. Partial unique index on `(workspace_id, tenant_id, fingerprint)` WHERE `fingerprint IS NOT NULL AND status NOT IN ('expired', 'failed')`; additional indexes on `(workspace_id, tenant_id, generated_at)` and `(status, expires_at)`.
|
||||
- **FR-002**: System MUST register `REVIEW_PACK_VIEW` and `REVIEW_PACK_MANAGE` in the canonical RBAC capability registry.
|
||||
- **FR-003**: System MUST implement a `ReviewPackResource` Filament Resource under Monitoring → Exports nav group with list, view, and modal-generate.
|
||||
- **FR-004**: System MUST implement a Tenant Dashboard card showing the latest pack's status, `generated_at`, `expires_at`, and "Generate pack" (manage) / "Download latest" (view/manage) actions.
|
||||
- **FR-005**: System MUST implement `tenant.review_pack.generate` OperationRun with active-run dedupe (unique active run per workspace+tenant+run_type) and content dedupe (fingerprint uniqueness per workspace+tenant).
|
||||
- **FR-006**: System MUST implement a queued Job that collects stored reports (`permission_posture`, `entra.admin_roles`), findings (`drift`, `permission_posture`, `entra_admin_roles`), tenant hardening/write-safety status fields, and recent operation_runs (last 30 days), then assembles and stores a ZIP artifact. The job MUST NOT trigger Graph API calls; it operates exclusively on existing DB data. `summary.json` MUST include a `data_freshness` object with per-source timestamps indicating the age of each data input.
|
||||
- **FR-007**: The generated ZIP MUST contain: `summary.json`, `findings.csv`, `operations.csv` (default on, togglable), `hardening.json`, `reports/permission_posture.json`, `reports/entra_admin_roles.json`, `metadata.json`. File order in ZIP MUST be stable and deterministic. `metadata.json` MUST include at minimum: `generator_version` (app version string), `generated_at` (ISO 8601), `tenant_id`, `tenant_external_id`, `pack_fingerprint`, `options` (include_pii, include_operations), and `data_model_version` (schema version for forward compatibility).
|
||||
- **FR-008**: The export MUST NOT include webhook URLs, email recipient lists, tokens, client secrets, or raw Graph API response dumps.
|
||||
- **FR-009**: When `include_pii = false`, system MUST redact `principal.display_name` across all exported files, retaining object IDs and types. v1 scope: `principal.display_name` is the only targeted PII field; additional PII fields discovered during implementation follow the same redaction pattern.
|
||||
- **FR-010**: System MUST compute a deterministic fingerprint: `sha256(tenant_id + include_pii + include_operations + sorted_report_fingerprints + max_finding_last_seen_at + hardening_status_tuple)`; a `ready` unexpired pack with the same fingerprint MUST be reused without creating a new artifact.
|
||||
- **FR-011**: Pack files MUST be written to `Storage::disk('exports')` (private, non-public); downloads MUST use signed temporary URLs (`URL::signedRoute()`) with a configurable TTL; the URL generation endpoint MUST enforce `REVIEW_PACK_VIEW` (non-members receive 404); the signed download controller validates the signature but does not require an active session.
|
||||
- **FR-012**: System MUST compute and persist `sha256` and `file_size` for each generated pack.
|
||||
- **FR-013**: System MUST set `expires_at` on each pack (default: 90 days from `generated_at`; configurable via `config('tenantpilot.review_pack.retention_days')`).
|
||||
- **FR-014**: System MUST provide Artisan command `tenantpilot:review-pack:prune` that marks packs past `expires_at` as `expired` and deletes their storage files. Hard-delete of DB rows is off by default; when invoked with `--hard-delete`, rows that have been in `expired` status for longer than the grace period (default: 30 days, configurable via `config('tenantpilot.review_pack.hard_delete_grace_days')`) are permanently removed. Without `--hard-delete`, expired rows remain queryable for audit trails.
|
||||
- **FR-015**: System MUST wire `tenantpilot:entra-roles:dispatch` in the Laravel console scheduler with at least daily frequency. System SHOULD wire `tenantpilot:posture:dispatch` when the command infrastructure exists (deferred — command does not yet exist; see research.md §7).
|
||||
- **FR-016**: System MUST remove or hide the `sla_due` event_type option from the AlertRule form field without breaking existing AlertRule data.
|
||||
- **FR-017**: System MUST send a DB notification to the initiator when a pack transitions to `ready` (with download link) or `failed` (with reason).
|
||||
- **FR-018**: On generation failure, system MUST record a stable `reason_code` (`review_pack.generation_failed` or `review_pack.storage_failed`) on the OperationRun with a sanitized error message.
|
||||
- **FR-019**: Non-members accessing any review pack route MUST receive 404; members lacking the required capability MUST receive 403.
|
||||
|
||||
---
|
||||
|
||||
## UI Action Matrix *(mandatory)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance | Row Actions (max 2 visible) | Bulk Actions | Empty-State CTA(s) | View Header Actions | Destructive Confirmation | Audit log? | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| ReviewPackResource (List) | app/Filament/Resources/ReviewPackResource.php | "Generate Pack" modal (REVIEW_PACK_MANAGE) | Clickable row to View page | "Download" (ready only, REVIEW_PACK_VIEW), "Expire" (REVIEW_PACK_MANAGE, destructive + requiresConfirmation) | — | "No review packs yet" + "Generate first pack" CTA | — | Expire requires requiresConfirmation() | Yes (OperationRun) | Generate opens options modal with include_pii toggle |
|
||||
| ReviewPackResource (View) | app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php | "Download" (REVIEW_PACK_VIEW), "Regenerate" (REVIEW_PACK_MANAGE, Owner/Manager) | — | — | — | — | Download, Regenerate | Regenerate requires requiresConfirmation() if ready pack exists | Yes (OperationRun) | Infolist layout; status badge per BADGE-001 |
|
||||
| Tenant Dashboard Card | app/Filament/Widgets/TenantReviewPackCard.php | — | — | "Generate pack" (REVIEW_PACK_MANAGE), "Download latest" (REVIEW_PACK_VIEW) | — | "No pack yet — Generate first" | — | — | Yes (via OperationRun) | Shows latest pack status, generated_at, expires_at |
|
||||
|
||||
---
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **ReviewPack**: Represents a generated ZIP artifact scoped to a workspace + tenant. Carries status lifecycle (queued → generating → ready/failed/expired), fingerprint for dedupe, file location references (disk, path, sha256, size), retention deadline, and link to the generating OperationRun.
|
||||
- **OperationRun (`tenant.review_pack.generate`)**: Tracks generation lifecycle. Provides observability, dedupe anchor, and failure reason_code. Existing entity; new run_type added.
|
||||
- **StoredReport** (existing, unchanged): Evidence snapshots consumed by the pack generator (`permission_posture`, `entra.admin_roles`).
|
||||
- **Finding** (existing, unchanged): Actionable deviations exported to `findings.csv` (`drift`, `permission_posture`, `entra_admin_roles` types).
|
||||
- **AlertRule** (existing, modified): `sla_due` event_type option removed from the form field.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A user can trigger pack generation and receive a downloadable ZIP within 60 seconds for a tenant with up to 1,000 findings and 10 stored reports under normal background worker load.
|
||||
- **SC-002**: Duplicate generation requests with identical inputs produce exactly one `ready` pack row and one file on disk — verified by concurrent trigger test.
|
||||
- **SC-003**: All generated ZIPs pass SHA-256 integrity verification against the `sha256` value stored in the database (byte-for-byte match on re-download).
|
||||
- **SC-004**: Non-members receive a 404 with no pack information disclosed; view-only members cannot trigger generation (403); enforced server-side in all code paths.
|
||||
- **SC-005**: Packs past their `expires_at` are expired and their files deleted by the nightly prune command with no manual intervention; verified by test and schedule assertion.
|
||||
- **SC-006**: The ReviewPack list renders with search, sort by generated_at and status, and status + date-range filters; the empty state displays a specific title, explanation, and exactly one primary CTA.
|
||||
- **SC-007**: The `sla_due` event_type option no longer appears in the AlertRule form; existing AlertRule rows with `sla_due` are not corrupted (backward-compatible removal).
|
||||
- **SC-008**: The Laravel scheduler contains both `tenantpilot:posture:dispatch` and `tenantpilot:entra-roles:dispatch` assertable via a Pest schedule test without full job execution.
|
||||
|
||||
---
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-23
|
||||
|
||||
- Q: Download delivery mechanism (signed temporary URL vs session-authenticated stream vs both)? → A: Signed temporary URL (`URL::signedRoute()`, self-contained, expires after configurable TTL). Notification links are self-contained; RBAC check happens at URL generation time.
|
||||
- Q: Expire/Delete authorization — reuse `REVIEW_PACK_MANAGE` or add a separate `REVIEW_PACK_DELETE` capability? → A: Reuse `REVIEW_PACK_MANAGE`; the `->requiresConfirmation()` dialog provides the safety gate. A dedicated delete capability can be split out later if needed.
|
||||
- Q: Stale data threshold — should the generation job trigger on-demand Graph scans if stored reports are older than 24h? → A: No. Generate with existing data (DB-only, no Graph calls). Include `data_freshness` timestamps per source in `summary.json` so the reviewer can judge staleness and optionally trigger manual scans before regenerating.
|
||||
- Q: Prune grace period for hard-delete — how long do expired rows stay in the DB, and is hard-delete on by default? → A: 30-day grace period after expiry. Hard-delete is off by default; operators opt in via `--hard-delete` flag on the prune command. Expired rows remain queryable for audit purposes until explicitly purged.
|
||||
- Q: `exports` disk storage backend — local disk or S3-compatible object storage? → A: Local disk (`storage/app/private/exports/`) with a persistent Docker volume mapped in Dokploy. Downloads streamed via signed route controller. S3 can be swapped later by changing the disk config without code changes.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `stored_reports`, `findings`, `operation_runs`, tenant, and workspace models exist per Specs 104/105/108; this spec adds no structural changes to those tables.
|
||||
- The `exports` filesystem disk is configured as a local private disk at `storage/app/private/exports/` in `config/filesystems.php`. In Dokploy deployments, this path MUST be mapped to a persistent Docker volume. S3-compatible storage can be substituted later by changing the disk driver without code changes.
|
||||
- The `OperationRun` active-run dedupe guard follows the same pattern established by prior specs; `tenant.review_pack.generate` is added to the run_type registry.
|
||||
- The canonical RBAC capability registry location is established by Specs 001/066; `REVIEW_PACK_VIEW` and `REVIEW_PACK_MANAGE` are added following the same pattern.
|
||||
- `principal.display_name` is the primary PII field in Entra admin roles stored_report payloads; additional PII fields follow the same redaction pattern if discovered.
|
||||
- Default `include_pii = true` for internal MSP use; workspace-level override is out of scope for v1.
|
||||
- "Force regenerate" (superseding a ready unexpired pack) is an optional Owner/Manager action; if deferred, the UI simply omits the button in v1.
|
||||
- Findings exported are scoped to `status IN (new, acknowledged)` and `last_seen_at >= now() - 30 days`; exact scope confirmed against Finding model scopes during implementation.
|
||||
- ZIP assembly uses PHP ZipArchive with deterministic, alphabetical file insertion order for stable fingerprinting.
|
||||
270
specs/109-review-pack-export/tasks.md
Normal file
270
specs/109-review-pack-export/tasks.md
Normal file
@ -0,0 +1,270 @@
|
||||
# Tasks: Tenant Review Pack Export v1 (CSV + ZIP)
|
||||
|
||||
**Input**: Design documents from `/specs/109-review-pack-export/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api-contracts.md, quickstart.md
|
||||
|
||||
**Tests**: Required (Pest Feature tests). Seven test files covering generation, download, RBAC, prune, resource, widget, and schedule.
|
||||
**Operations**: `OperationRun` of type `tenant.review_pack.generate` tracks all generation runs. Failure `reason_code` stored in `context` jsonb column. Active-run dedupe via application-level guard (`scopeActive()`).
|
||||
**RBAC**:
|
||||
- Gate/Policy enforcement: `REVIEW_PACK_VIEW` (list + download), `REVIEW_PACK_MANAGE` (generate + expire).
|
||||
- Non-member → 404 (deny-as-not-found, workspace/tenant isolation). Member missing capability → 403.
|
||||
- Capabilities registered in canonical `Capabilities.php` registry — no raw strings in feature code.
|
||||
- Authorization plane: tenant-context (`/admin/t/{tenant}/...`).
|
||||
- ReviewPackResource does NOT participate in global search (intentionally excluded; no `$recordTitleAttribute`).
|
||||
- Destructive actions (Expire) use `->requiresConfirmation()`.
|
||||
- Tests include positive + negative authorization scenarios.
|
||||
**Filament UI Action Surfaces**: UI Action Matrix in spec.md fulfilled. List: header "Generate Pack" modal, row "Download" + "Expire" (max 2 visible), empty state CTA. View: header "Download" + "Regenerate". Dashboard card: "Generate" + "Download" conditional.
|
||||
**Filament UI UX-001**: View page uses Infolist (not disabled edit form). All modal fields inside Sections. Status badges use BADGE-001. Empty state has title + explanation + 1 CTA. Clickable rows via `recordUrl()`.
|
||||
**Badges**: `ReviewPackStatus` domain added to `BadgeDomain` enum with `ReviewPackStatusBadge` mapper per BADGE-001. Tests assert each status renders correct badge.
|
||||
|
||||
**Organization**: Tasks grouped by user story per spec.md priorities (P1 → P2 → P3).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
|
||||
- **[Story]**: Which user story (US1–US5) this task belongs to
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Database schema — must complete before model/factory creation
|
||||
|
||||
- [X] T001 Create migration for `review_packs` table with all columns (id, workspace_id FK, tenant_id FK, operation_run_id FK nullable, initiated_by_user_id FK nullable, status, fingerprint, previous_fingerprint, summary jsonb, options jsonb, file_disk, file_path, file_size bigint, sha256, generated_at timestampTz, expires_at timestampTz, timestamps), three indexes, and partial unique index `WHERE fingerprint IS NOT NULL AND status NOT IN ('expired','failed')` in `database/migrations/XXXX_create_review_packs_table.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core enums, config, capabilities, badges, model, and factory that ALL user stories depend on
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [X] T002 [P] Create `ReviewPackStatus` enum with 5 string-backed cases (Queued, Generating, Ready, Failed, Expired) in `app/Support/ReviewPackStatus.php`
|
||||
- [X] T003 [P] Add `ReviewPackGenerate` case with value `tenant.review_pack.generate` to `app/Support/OperationRunType.php`
|
||||
- [X] T004 [P] Add `REVIEW_PACK_VIEW = 'review_pack.view'` and `REVIEW_PACK_MANAGE = 'review_pack.manage'` constants to `app/Support/Auth/Capabilities.php`
|
||||
- [X] T005 [P] Add `ReviewPackStatus = 'review_pack_status'` case to `app/Support/Badges/BadgeDomain.php`
|
||||
- [X] T006 [P] Create `ReviewPackStatusBadge` mapper returning `BadgeSpec` for 5 statuses (queued→warning, generating→info, ready→success, failed→danger, expired→gray) in `app/Support/Badges/Mappers/ReviewPackStatusBadge.php`
|
||||
- [X] T007 [P] Add `exports` disk with local driver at `storage_path('app/private/exports')`, `serve => false`, `throw => true` to `config/filesystems.php`
|
||||
- [X] T008 [P] Add `review_pack` config section with retention_days (90), hard_delete_grace_days (30), download_url_ttl_minutes (60), include_pii_default (true), include_operations_default (true) to `config/tenantpilot.php`
|
||||
- [X] T009 Create `ReviewPack` model with `DerivesWorkspaceIdFromTenant` + `HasFactory` traits, `guarded = []`, casts (summary/options→array, generated_at/expires_at→datetime, file_size→integer), relationships (workspace, tenant, operationRun, initiator via initiated_by_user_id), scopes (ready, expired, pastRetention, forTenant, latestReady), and STATUS_* constants in `app/Models/ReviewPack.php`
|
||||
- [X] T010 Create `ReviewPackFactory` with default definition (ready state) and 5 named states (queued, generating, ready, failed, expired) that set correct status + nullable file fields per state in `database/factories/ReviewPackFactory.php`
|
||||
- [X] T011 Run migration and verify model + factory (`vendor/bin/sail artisan migrate && vendor/bin/sail artisan test --compact --filter=ReviewPack`)
|
||||
|
||||
**Checkpoint**: Foundation ready — user story implementation can now begin
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Generate and Download Review Pack (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Produce an auditable ZIP artifact (summary.json, findings.csv, operations.csv, hardening.json, reports/*.json, metadata.json) from cached DB data, store privately on exports disk, deliver via signed URL, and provide full Filament UI.
|
||||
|
||||
**Independent Test**: Create tenant with stored reports + findings, trigger generation as REVIEW_PACK_MANAGE user, assert OperationRun created, review_packs row transitions to ready, ZIP file exists on exports disk with correct 7-file set, download via signed URL returns correct headers and content.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Implement `ReviewPackService` with `generate(Tenant, User, array $options): ReviewPack` (creates OperationRun + ReviewPack + dispatches job), `computeFingerprint(Tenant, array $options): string` (SHA-256 of tenant_id + options + report fingerprints + max finding last_seen_at + hardening tuple), `generateDownloadUrl(ReviewPack): string` (URL::signedRoute with configurable TTL), `findExistingPack(Tenant, string $fingerprint): ?ReviewPack` (ready + unexpired dedupe check), and `checkActiveRun(Tenant): bool` (active OperationRun guard) in `app/Services/ReviewPackService.php`
|
||||
- [X] T013 [US1] Implement `GenerateReviewPackJob` (ShouldQueue) with 12-step pipeline: load records, mark running, collect StoredReports (permission_posture + entra.admin_roles), collect Findings (status in open/acknowledged, chunked), collect tenant hardening fields (via accessor, not raw DB), collect recent OperationRuns (30 days), compute data_freshness, build file map with PII redaction when include_pii=false, assemble ZIP via ZipArchive (alphabetical order, temp file), compute SHA-256, store on exports disk, update ReviewPack (status=ready, fingerprint, sha256, file_size, file_path, file_disk, generated_at, summary, expires_at), mark OperationRun completed; on failure: mark failed with reason_code in context, notify initiator in `app/Jobs/GenerateReviewPackJob.php`
|
||||
- [X] T014 [P] [US1] Create `ReviewPackStatusNotification` (database channel) with conditional rendering: ready payload (title, body with tenant name, View action URL) and failed payload (title, body with sanitized reason, View action URL) in `app/Notifications/ReviewPackStatusNotification.php`
|
||||
- [X] T015 [US1] Create `ReviewPackDownloadController` with single `__invoke(Request, ReviewPack)` method: validate status is ready, stream file via `Storage::disk($pack->file_disk)->download()` with headers Content-Type application/zip, Content-Disposition attachment with filename `review-pack-{tenant_external_id}-{YYYY-MM-DD}.zip`, X-Review-Pack-SHA256; return 404 for expired/non-ready packs in `app/Http/Controllers/ReviewPackDownloadController.php`
|
||||
- [X] T016 [US1] Add signed download route `GET /admin/review-packs/{reviewPack}/download` named `admin.review-packs.download` with `signed` middleware in `routes/web.php`
|
||||
- [X] T017 [US1] Create `ReviewPackResource` with: table columns (status badge via BADGE-001, tenant name, generated_at datetime, expires_at datetime, file_size formatted), `recordUrl()` for clickable rows to ViewReviewPack, header action "Generate Pack" (modal with Section containing include_pii Toggle + include_operations Toggle, authorized by REVIEW_PACK_MANAGE, calls ReviewPackService::generate), row actions "Download" (visible when ready, REVIEW_PACK_VIEW, openUrlInNewTab with signed URL) + "Expire" (REVIEW_PACK_MANAGE, color danger, requiresConfirmation, sets status=expired + deletes file), empty state with title "No review packs yet" + description + "Generate first pack" CTA, filters (status SelectFilter, generated_at date range), search on tenant name, sort on generated_at/status, nav group Monitoring → Exports in `app/Filament/Resources/ReviewPackResource.php`
|
||||
- [X] T018 [US1] Create `ListReviewPacks` page extending ListRecords in `app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`
|
||||
- [X] T019 [US1] Create `ViewReviewPack` page with Infolist layout: status badge (BADGE-001), summary section (data_freshness per-source timestamps, generated_at, expires_at, file_size, sha256), options section (include_pii, include_operations), initiator + OperationRun link; header actions "Download" (REVIEW_PACK_VIEW, visible when ready) + "Regenerate" (REVIEW_PACK_MANAGE, requiresConfirmation when ready pack exists, pre-fills current options, sets previous_fingerprint) in `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||
- [X] T020 [P] [US1] Create `TenantReviewPackCard` widget with 5 display states: no pack ("No review pack yet" + Generate CTA), queued/generating (status badge + "Generation in progress"), ready (badge + generated_at + expires_at + file_size + Download action REVIEW_PACK_VIEW + "Generate new" REVIEW_PACK_MANAGE), failed (badge + sanitized reason + Retry=Generate REVIEW_PACK_MANAGE), expired (badge + expiry date + "Generate new" REVIEW_PACK_MANAGE); data = latest ReviewPack for current tenant in `app/Filament/Widgets/TenantReviewPackCard.php`
|
||||
- [X] T021 [P] [US1] Create generation tests: happy path (job processes, status→ready, file on disk, sha256+file_size populated, notification sent to initiator, OperationRun completed+success), failure path (exception → status→failed, OperationRun failed, reason_code in context, failure notification), empty reports (job succeeds with empty report sections, status→ready), PII redaction (include_pii=false → display_name replaced with placeholder, UUIDs retained), ZIP contents (exactly 7 files in alphabetical order: findings.csv, hardening.json, metadata.json, operations.csv, reports/entra_admin_roles.json, reports/permission_posture.json, summary.json), performance baseline (seed 1,000 findings + 10 stored reports and assert job completes within 60s per SC-001) in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
- [X] T022 [P] [US1] Create download tests: signed URL → 200 with Content-Type application/zip + Content-Disposition + X-Review-Pack-SHA256 headers; expired signature → 403; expired pack (status=expired) → 404; non-ready pack (status=queued) → 404; non-existent pack → 404 in `tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
|
||||
- [X] T023 [P] [US1] Create resource Livewire tests: list page renders with columns, empty state shows CTA, generate action modal opens with toggle fields, view page displays infolist sections with correct data, expire action requires confirmation and updates status in `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
- [X] T024 [P] [US1] Create widget Livewire tests: no-pack state shows generate CTA, ready state shows download + generate actions, generating state shows in-progress message, failed state shows retry action, expired state shows generate action in `tests/Feature/ReviewPack/ReviewPackWidgetTest.php`
|
||||
|
||||
**Checkpoint**: User Story 1 fully functional — can generate, download, view, and manage review packs
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Fingerprint Dedupe (Priority: P2)
|
||||
|
||||
**Goal**: Prevent duplicate pack generation; reuse existing ready pack with same fingerprint; reject concurrent generation for same tenant.
|
||||
|
||||
**Independent Test**: Trigger generation twice with identical inputs, assert only one ready ReviewPack row and one file on disk.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T025 [US2] Add fingerprint dedupe integration tests: identical inputs → returns existing ready pack (no new row, no new file), active OperationRun for same tenant → rejection with "generation already in progress" notification, expired pack with same fingerprint → allows new generation (partial unique index excludes expired), different options (include_pii toggled) → new pack with different fingerprint, fingerprint computation is deterministic (same inputs → same hash) in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
|
||||
**Checkpoint**: Fingerprint dedupe verified — duplicate requests handled correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — RBAC Enforcement (Priority: P2)
|
||||
|
||||
**Goal**: Non-members get 404 (deny-as-not-found), view-only members can list + download but not generate, manage members can generate + expire.
|
||||
|
||||
**Independent Test**: Test each role (no membership, REVIEW_PACK_VIEW, REVIEW_PACK_MANAGE) against each action (list, view, download, generate, expire) and assert correct HTTP status or Filament authorization result.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Create `ReviewPackPolicy` with `viewAny`, `view`, `create`, `delete` methods mapping to `REVIEW_PACK_VIEW` (viewAny, view) and `REVIEW_PACK_MANAGE` (create, delete) capability checks via canonical registry; enforce workspace_id + tenant_id scope (non-member → null = 404 semantics) in `app/Policies/ReviewPackPolicy.php`
|
||||
- [X] T027 [US3] Create comprehensive RBAC enforcement tests: non-member → 404 on list page, non-member → 404 on view page, non-member → 404 on download route; REVIEW_PACK_VIEW member → list succeeds, view succeeds, download signed URL succeeds, generate action hidden/403; REVIEW_PACK_MANAGE member → generate succeeds, expire succeeds, download succeeds; signed URL without valid signature → 403; expire action shows requiresConfirmation dialog before execution in `tests/Feature/ReviewPack/ReviewPackRbacTest.php`
|
||||
|
||||
**Checkpoint**: RBAC enforcement verified across all surfaces (resource, controller, widget)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Retention & Prune (Priority: P3)
|
||||
|
||||
**Goal**: Auto-expire packs past retention deadline, delete storage files, optionally hard-delete DB rows after grace period. Clean up dead `sla_due` AlertRule option.
|
||||
|
||||
**Independent Test**: Create ReviewPack with `expires_at` in past, run prune command, verify status=expired and file deleted from exports disk.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T028 [US4] Create `PruneReviewPacksCommand` with signature `tenantpilot:review-pack:prune {--hard-delete}`: query ready packs where expires_at < now → set status=expired + delete file from exports disk; when --hard-delete: query expired packs where updated_at < now - grace_days → hard-delete rows; output summary "{n} packs expired, {m} packs hard-deleted" in `app/Console/Commands/PruneReviewPacksCommand.php`
|
||||
- [X] T029 [US4] Add prune command schedule entry `tenantpilot:review-pack:prune` with `daily()` + `withoutOverlapping()` in `routes/console.php`
|
||||
- [X] T030 [P] [US4] Remove or hide `sla_due` event_type option from AlertRuleResource form dropdown (keep EVENT_SLA_DUE constant on model for backward compatibility) in `app/Filament/Resources/AlertRuleResource.php`
|
||||
- [X] T031 [US4] Create prune tests: past retention → status=expired + file deleted from disk; future retention → unaffected; --hard-delete past grace_days → rows removed from DB; --hard-delete within grace_days → rows kept; command output includes correct counts; AlertRule form no longer shows sla_due option in dropdown in `tests/Feature/ReviewPack/ReviewPackPruneTest.php`
|
||||
|
||||
**Checkpoint**: Retention automation verified — packs expire and files cleaned up automatically on schedule
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — Scheduled Scan Wiring (Priority: P3)
|
||||
|
||||
**Goal**: Ensure daily permission-posture and Entra admin-roles scans are wired in the scheduler so review packs consume fresh cached data.
|
||||
|
||||
**Independent Test**: Assert the Laravel console schedule contains the expected dispatch entries with at least daily frequency.
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T032 [US5] Verify existing Entra admin roles schedule closure in `routes/console.php` runs daily; add TODO comment for `tenantpilot:posture:dispatch` command (creation deferred per research.md — command infrastructure does not yet exist; FR-015 partially fulfilled)
|
||||
- [X] T033 [US5] Create schedule assertion test: Entra admin roles dispatch appears with daily frequency; document posture:dispatch deferral as explicit test skip with rationale in `tests/Feature/ReviewPack/ReviewPackScheduleTest.php`
|
||||
|
||||
**Checkpoint**: Scheduled scan wiring verified or documented as deferred
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Code quality, formatting, and end-to-end validation
|
||||
|
||||
- [X] T034 [P] Run Pint formatter on all new and modified files (`vendor/bin/sail bin pint --dirty --format agent`)
|
||||
- [X] T035 Run full ReviewPack test suite and fix any failures (`vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/`)
|
||||
- [X] T036 Validate quickstart.md scenarios end-to-end: create tenant with stored reports + findings, generate pack, verify ZIP contents (7 files), download via signed URL, expire pack, run prune command, confirm file deleted
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 (migration must exist) — BLOCKS all user stories
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 — delivers complete MVP
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 (needs generate pipeline to test dedupe)
|
||||
- **US3 (Phase 5)**: Depends on Phase 3 (needs resource + controller to test RBAC)
|
||||
- **US4 (Phase 6)**: Depends on Phase 2 only (prune operates on model, not UI) — can parallel with US1
|
||||
- **US5 (Phase 7)**: Depends on Phase 2 only — can parallel with US1
|
||||
- **Polish (Phase 8)**: Depends on all desired phases being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Core MVP — blocked only by Foundational phase
|
||||
- **US2 (P2)**: Tests dedupe behavior in generate pipeline — depends on US1
|
||||
- **US3 (P2)**: Tests RBAC across all surfaces — depends on US1
|
||||
- **US4 (P3)**: Prune command + schedule — can start after Foundational (parallel with US1 if desired)
|
||||
- **US5 (P3)**: Schedule wiring — can start after Foundational (parallel with US1 if desired)
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Service (T012) before Job (T013) — job calls service methods
|
||||
- Controller (T015) + Route (T016) before Resource download action (T017)
|
||||
- Resource (T017) before Pages (T018, T019)
|
||||
- All implementations before their corresponding tests
|
||||
- Service (T012) before Widget (T020)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 2**: All tasks T002–T008 run in parallel (7 independent files)
|
||||
- **Phase 3**: T014 (notification) parallels T013 (job); T020 (widget) parallels T017–T019 (resource/pages) after T012 (service) completes
|
||||
- **Phase 3 tests**: T021–T024 all run in parallel (4 independent test files)
|
||||
- **Cross-phase**: US4 (T028–T031) can parallel with US1 (T012–T024) if team capacity allows
|
||||
- **Cross-phase**: US5 (T032–T033) can parallel with US1
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Foundational Phase
|
||||
|
||||
```bash
|
||||
# Launch all parallel foundational tasks together:
|
||||
T002: ReviewPackStatus enum → app/Support/ReviewPackStatus.php
|
||||
T003: OperationRunType case → app/Support/OperationRunType.php
|
||||
T004: Capabilities constants → app/Support/Auth/Capabilities.php
|
||||
T005: BadgeDomain case → app/Support/Badges/BadgeDomain.php
|
||||
T006: ReviewPackStatusBadge → app/Support/Badges/Mappers/ReviewPackStatusBadge.php
|
||||
T007: exports disk config → config/filesystems.php
|
||||
T008: review_pack config → config/tenantpilot.php
|
||||
|
||||
# Then sequentially (dependency chain):
|
||||
T009: ReviewPack model → app/Models/ReviewPack.php (needs T002)
|
||||
T010: ReviewPackFactory → database/factories/ReviewPackFactory.php (needs T009)
|
||||
T011: Migrate + verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: US1 Tests
|
||||
|
||||
```bash
|
||||
# After all US1 implementations complete, launch test files in parallel:
|
||||
T021: ReviewPackGenerationTest → tests/Feature/ReviewPack/ReviewPackGenerationTest.php
|
||||
T022: ReviewPackDownloadTest → tests/Feature/ReviewPack/ReviewPackDownloadTest.php
|
||||
T023: ReviewPackResourceTest → tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||
T024: ReviewPackWidgetTest → tests/Feature/ReviewPack/ReviewPackWidgetTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (migration)
|
||||
2. Complete Phase 2: Foundational (model, enums, config, badge)
|
||||
3. Complete Phase 3: User Story 1 (service, job, controller, resource, widget, tests)
|
||||
4. **STOP and VALIDATE**: Run `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/`
|
||||
5. Deploy/demo if ready — full generate + download + UI works
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Foundation ready
|
||||
2. Add US1 → Generate + Download + UI works → Deploy MVP
|
||||
3. Add US2 → Dedupe verified → Add US3 → RBAC hardened → Deploy
|
||||
4. Add US4 → Retention automation → Add US5 → Schedule wiring → Deploy
|
||||
5. Polish → Final validation → Production-ready
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: US1 (core pipeline + UI)
|
||||
- Developer B: US4 (prune command — independent of UI)
|
||||
3. After US1 complete:
|
||||
- Developer A: US2 + US3 (dedupe + RBAC tests)
|
||||
- Developer B: US5 (schedule wiring)
|
||||
4. Polish phase together
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies on incomplete tasks
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Tests are REQUIRED per project conventions (Pest Feature tests)
|
||||
- FR-015 (`tenantpilot:posture:dispatch`) partially deferred — command infrastructure does not yet exist (documented in research.md §7)
|
||||
- FR-016 (`sla_due` cleanup) is a minor AlertRule form change in US4 phase
|
||||
- ReviewPackResource does NOT participate in global search (intentional, per plan.md Filament v5 contract §3)
|
||||
- All destructive actions (Expire) use `->requiresConfirmation()` per Filament v5 blueprint §9
|
||||
- No custom frontend assets — standard Filament components only
|
||||
- Commit after each task or logical group; stop at any checkpoint to validate independently
|
||||
@ -3,7 +3,6 @@
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use App\Contracts\Hardening\WriteGateInterface;
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Hardening\IntuneRbacWriteGate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
16
tests/Feature/LivewireInterceptShimTest.php
Normal file
16
tests/Feature/LivewireInterceptShimTest.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('injects the Livewire intercept shim into Filament pages', function (): void {
|
||||
$this->get('/admin/login')
|
||||
->assertSuccessful()
|
||||
->assertSee('js/tenantpilot/livewire-intercept-shim.js', escape: false);
|
||||
});
|
||||
|
||||
it('ships a shim that waits for Livewire initialization', function (): void {
|
||||
$js = file_get_contents(public_path('js/tenantpilot/livewire-intercept-shim.js'));
|
||||
|
||||
expect($js)->toBeString();
|
||||
expect($js)->toContain('livewire:init');
|
||||
});
|
||||
174
tests/Feature/ReviewPack/ReviewPackDownloadTest.php
Normal file
174
tests/Feature/ReviewPack/ReviewPackDownloadTest.php
Normal file
@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────
|
||||
|
||||
function createReadyPackWithFile(?array $packOverrides = []): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$filePath = 'review-packs/'.$tenant->external_id.'/test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake-zip-content');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create(array_merge([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'sha256' => hash('sha256', 'PK-fake-zip-content'),
|
||||
], $packOverrides));
|
||||
|
||||
return [$user, $tenant, $pack];
|
||||
}
|
||||
|
||||
// ─── Happy Path: Signed URL → 200 ───────────────────────────
|
||||
|
||||
it('downloads a ready pack via signed URL with correct headers', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
|
||||
$response->assertDownload();
|
||||
});
|
||||
|
||||
// ─── Expired Signature → 403 ────────────────────────────────
|
||||
|
||||
it('rejects requests with an expired signature', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
|
||||
// Generate a signed URL that expires immediately
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->subMinute(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
// ─── Expired Pack → 404 ─────────────────────────────────────
|
||||
|
||||
it('returns 404 for an expired pack', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile([
|
||||
'status' => ReviewPackStatus::Expired->value,
|
||||
]);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Non-Ready Pack → 404 ───────────────────────────────────
|
||||
|
||||
it('returns 404 for a queued pack', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$pack = ReviewPack::factory()->queued()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Non-Existent Pack → 404 ────────────────────────────────
|
||||
|
||||
it('returns 404 for a non-existent pack', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => 99999],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Past Expiry Date → 404 ─────────────────────────────────
|
||||
|
||||
it('returns 404 when pack status is ready but expires_at is in the past', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile([
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Missing File on Disk → 404 ─────────────────────────────
|
||||
|
||||
it('returns 404 when file does not exist on disk', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => 'review-packs/does-not-exist.zip',
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$signedUrl = URL::signedRoute(
|
||||
'admin.review-packs.download',
|
||||
['reviewPack' => $pack->getKey()],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── Unsigned URL → 403 ─────────────────────────────────────
|
||||
|
||||
it('returns 403 for an unsigned URL', function (): void {
|
||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||
|
||||
$response = $this->actingAs($user)->get(
|
||||
route('admin.review-packs.download', ['reviewPack' => $pack->getKey()]),
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
428
tests/Feature/ReviewPack/ReviewPackGenerationTest.php
Normal file
428
tests/Feature/ReviewPack/ReviewPackGenerationTest.php
Normal file
@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────
|
||||
|
||||
function seedTenantWithData(Tenant $tenant): void
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'posture_score' => 86,
|
||||
'required_count' => 14,
|
||||
'granted_count' => 12,
|
||||
'permissions' => [
|
||||
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => [
|
||||
'roles' => [
|
||||
[
|
||||
'displayName' => 'Global Administrator',
|
||||
'userPrincipalName' => 'admin@contoso.com',
|
||||
'role_template_id' => '62e90394-69f5-4237-9190-012177145e10',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->count(3)
|
||||
->create(['tenant_id' => (int) $tenant->getKey()]);
|
||||
}
|
||||
|
||||
// ─── Happy Path ──────────────────────────────────────────────
|
||||
|
||||
it('generates a review pack end-to-end (happy path)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
expect($pack)->toBeInstanceOf(ReviewPack::class);
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Queued->value);
|
||||
|
||||
// Dispatch the queued job synchronously
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
expect($pack->sha256)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->file_size)->toBeGreaterThan(0);
|
||||
expect($pack->file_path)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->file_disk)->toBe('exports');
|
||||
expect($pack->generated_at)->not->toBeNull();
|
||||
expect($pack->expires_at)->not->toBeNull();
|
||||
expect($pack->fingerprint)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->summary)->toBeArray();
|
||||
expect($pack->summary['finding_count'])->toBe(3);
|
||||
expect($pack->summary['report_count'])->toBe(2);
|
||||
|
||||
// File exists on disk
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
|
||||
// OperationRun completed
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($opRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
// Notification sent (standard OperationRunCompleted via OperationRunService)
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
|
||||
// ─── Failure Path ──────────────────────────────────────────────
|
||||
|
||||
it('marks pack as failed when generation throws an exception', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
// Replace the exports disk with a mock that throws on put()
|
||||
$fakeDisk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class);
|
||||
$fakeDisk->shouldReceive('put')
|
||||
->andThrow(new \RuntimeException('Simulated storage failure'));
|
||||
|
||||
Storage::shouldReceive('disk')
|
||||
->with('exports')
|
||||
->andReturn($fakeDisk);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
|
||||
try {
|
||||
app()->call([$job, 'handle']);
|
||||
} catch (\RuntimeException) {
|
||||
// Expected — the job re-throws after marking failed
|
||||
}
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Failed->value);
|
||||
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($opRun->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||
expect($opRun->failure_summary)->toBeArray();
|
||||
expect($opRun->failure_summary[0]['code'])->toBe('generation_error');
|
||||
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
|
||||
// ─── Empty Reports ──────────────────────────────────────────────
|
||||
|
||||
it('succeeds with empty reports and findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
expect($pack->summary['finding_count'])->toBe(0);
|
||||
expect($pack->summary['report_count'])->toBe(0);
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
});
|
||||
|
||||
// ─── PII Redaction ──────────────────────────────────────────────
|
||||
|
||||
it('redacts PII when include_pii is false', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, ['include_pii' => false]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
|
||||
// Read the generated ZIP to verify PII redaction
|
||||
$zipContent = Storage::disk('exports')->get($pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test-zip-');
|
||||
file_put_contents($tempFile, $zipContent);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
// Check metadata.json redacts tenant name
|
||||
$metadata = json_decode($zip->getFromName('metadata.json'), true);
|
||||
expect($metadata['tenant_name'])->toBe('[REDACTED]');
|
||||
expect($metadata['options']['include_pii'])->toBeFalse();
|
||||
|
||||
// Check findings.csv redacts title in rows
|
||||
$findingsCsv = $zip->getFromName('findings.csv');
|
||||
expect($findingsCsv)->toContain('[REDACTED]');
|
||||
|
||||
// Check entra_admin_roles.json redacts displayName
|
||||
$entraReport = json_decode($zip->getFromName('reports/entra_admin_roles.json'), true);
|
||||
|
||||
if (! empty($entraReport) && isset($entraReport['roles'])) {
|
||||
foreach ($entraReport['roles'] as $role) {
|
||||
if (isset($role['displayName'])) {
|
||||
expect($role['displayName'])->toBe('[REDACTED]');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
});
|
||||
|
||||
// ─── ZIP Contents ──────────────────────────────────────────────
|
||||
|
||||
it('produces a ZIP with exactly 7 files in alphabetical order', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
$zipContent = Storage::disk('exports')->get($pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test-zip-');
|
||||
file_put_contents($tempFile, $zipContent);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
$files = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$files[] = $zip->getNameIndex($i);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
|
||||
$expectedFiles = [
|
||||
'findings.csv',
|
||||
'hardening.json',
|
||||
'metadata.json',
|
||||
'operations.csv',
|
||||
'reports/entra_admin_roles.json',
|
||||
'reports/permission_posture.json',
|
||||
'summary.json',
|
||||
];
|
||||
|
||||
expect($files)->toHaveCount(7);
|
||||
expect($files)->toEqual($expectedFiles);
|
||||
});
|
||||
|
||||
// ─── Service dispatches job ──────────────────────────────────
|
||||
|
||||
it('dispatches GenerateReviewPackJob when generate is called', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
Queue::assertPushed(GenerateReviewPackJob::class, function ($job) use ($pack) {
|
||||
return $job->reviewPackId === (int) $pack->getKey();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends queued database notification when review pack generation is requested', function (): void {
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$service->generate($tenant, $user);
|
||||
|
||||
Notification::assertSentTo($user, OperationRunQueued::class);
|
||||
});
|
||||
|
||||
// ─── OperationRun Type ──────────────────────────────────────────
|
||||
|
||||
it('creates an OperationRun of type review_pack_generate', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->type)->toBe(OperationRunType::ReviewPackGenerate->value);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Queued->value);
|
||||
});
|
||||
|
||||
// ─── Fingerprint Determinism ──────────────────────────────────
|
||||
|
||||
it('computes the same fingerprint for identical inputs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$options = ['include_pii' => true, 'include_operations' => true];
|
||||
$fp1 = $service->computeFingerprint($tenant, $options);
|
||||
$fp2 = $service->computeFingerprint($tenant, $options);
|
||||
|
||||
expect($fp1)->toBe($fp2);
|
||||
expect(strlen($fp1))->toBe(64); // SHA-256 hex length
|
||||
});
|
||||
|
||||
// ─── Different options produce different fingerprints ─────────
|
||||
|
||||
it('computes different fingerprints when options differ', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$fp1 = $service->computeFingerprint($tenant, ['include_pii' => true, 'include_operations' => true]);
|
||||
$fp2 = $service->computeFingerprint($tenant, ['include_pii' => false, 'include_operations' => true]);
|
||||
|
||||
expect($fp1)->not->toBe($fp2);
|
||||
});
|
||||
|
||||
// ─── Fingerprint Dedupe (T025) ────────────────────────────────
|
||||
|
||||
it('returns existing ready pack when fingerprint matches (dedupe)', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$options = ['include_pii' => true, 'include_operations' => true];
|
||||
|
||||
// Compute the fingerprint that the service would compute with normalized options
|
||||
$fingerprint = $service->computeFingerprint($tenant, $options);
|
||||
|
||||
$pack1 = $service->generate($tenant, $user, $options);
|
||||
|
||||
// Manually set the pack to ready with the correct fingerprint so dedupe triggers
|
||||
$pack1->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => $fingerprint,
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
|
||||
// Second call with same options should return the existing pack
|
||||
$pack2 = $service->generate($tenant, $user, $options);
|
||||
|
||||
expect($pack2->getKey())->toBe($pack1->getKey());
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('allows new generation when existing pack with same fingerprint is expired', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
$options = ['include_pii' => true, 'include_operations' => true];
|
||||
|
||||
// Create an expired pack with a matching fingerprint
|
||||
$fingerprint = $service->computeFingerprint($tenant, ['include_pii' => true, 'include_operations' => true]);
|
||||
|
||||
ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
// Should create a new pack since existing is expired
|
||||
$newPack = $service->generate($tenant, $user, $options);
|
||||
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(2);
|
||||
expect($newPack->status)->toBe(ReviewPackStatus::Queued->value);
|
||||
});
|
||||
163
tests/Feature/ReviewPack/ReviewPackPruneTest.php
Normal file
163
tests/Feature/ReviewPack/ReviewPackPruneTest.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\ReviewPack;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('expires ready packs past retention and deletes their files', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$filePath = 'review-packs/test-expired.zip';
|
||||
Storage::disk('exports')->put($filePath, 'fake content');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'file_path' => $filePath,
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPack::STATUS_EXPIRED)
|
||||
->and(Storage::disk('exports')->exists($filePath))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not expire ready packs with future retention', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$filePath = 'review-packs/test-future.zip';
|
||||
Storage::disk('exports')->put($filePath, 'fake content');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'file_path' => $filePath,
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPack::STATUS_READY)
|
||||
->and(Storage::disk('exports')->exists($filePath))->toBeTrue();
|
||||
});
|
||||
|
||||
it('hard-deletes expired packs past grace period', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$graceDays = config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$pack = ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays($graceDays + 5),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune --hard-delete')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps expired packs within grace period when hard-deleting', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$pack = ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune --hard-delete')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('outputs correct counts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
// 2 packs past retention → expired
|
||||
ReviewPack::factory()->count(2)->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'expires_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->expectsOutputToContain('2 pack(s) expired, 0 pack(s) hard-deleted')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('outputs hard-delete counts when option is passed', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$graceDays = config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
// 1 pack past retention → expired
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// 1 expired pack past grace → hard-deleted
|
||||
ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays($graceDays + 10),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune --hard-delete')
|
||||
->expectsOutputToContain('1 pack(s) expired, 1 pack(s) hard-deleted')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('does not hard-delete without the flag', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$graceDays = config('tenantpilot.review_pack.hard_delete_grace_days', 30);
|
||||
|
||||
$pack = ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'initiated_by_user_id' => $user->id,
|
||||
'updated_at' => now()->subDays($graceDays + 10),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:review-pack:prune')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ReviewPack::query()->whereKey($pack->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('AlertRule form no longer shows sla_due option', function (): void {
|
||||
$options = AlertRuleResource::eventTypeOptions();
|
||||
|
||||
expect($options)->not->toHaveKey(AlertRule::EVENT_SLA_DUE)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_HIGH_DRIFT)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_COMPARE_FAILED)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_PERMISSION_MISSING)
|
||||
->and($options)->toHaveKey(AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH);
|
||||
});
|
||||
214
tests/Feature/ReviewPack/ReviewPackRbacTest.php
Normal file
214
tests/Feature/ReviewPack/ReviewPackRbacTest.php
Normal file
@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── Non-Member Access ───────────────────────────────────────
|
||||
|
||||
it('returns 404 for non-member on list page', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $targetTenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-member on view page', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $targetTenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-member on download route', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/rbac-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
// Note: download route uses signed middleware, not tenant-scoped RBAC.
|
||||
// Any user with a valid signature can download. This is by design.
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$this->actingAs($user)->get($signedUrl)->assertOk();
|
||||
});
|
||||
|
||||
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
|
||||
|
||||
it('allows readonly member to access list page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('allows readonly member to access view page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('allows readonly member to download via signed URL', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$filePath = 'review-packs/readonly-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||
|
||||
$this->actingAs($user)->get($signedUrl)->assertOk();
|
||||
});
|
||||
|
||||
it('disables generate action for readonly member', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack')
|
||||
->assertActionDisabled('generate_pack')
|
||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
||||
|
||||
it('allows owner to generate a review pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack')
|
||||
->assertActionEnabled('generate_pack');
|
||||
});
|
||||
|
||||
it('allows owner to expire a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/expire-rbac.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertTableActionVisible('expire', $pack)
|
||||
->callTableAction('expire', $pack);
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Expired->value);
|
||||
});
|
||||
|
||||
it('disables expire action for readonly member', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$filePath = 'review-packs/expire-readonly.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertTableActionVisible('expire', $pack)
|
||||
->assertTableActionDisabled('expire', $pack);
|
||||
});
|
||||
|
||||
// ─── Signed URL Security ────────────────────────────────────
|
||||
|
||||
it('rejects unsigned download URL with 403', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('admin.review-packs.download', ['reviewPack' => $pack->getKey()]));
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
283
tests/Feature/ReviewPack/ReviewPackResourceTest.php
Normal file
283
tests/Feature/ReviewPack/ReviewPackResourceTest.php
Normal file
@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── List Page ───────────────────────────────────────────────
|
||||
|
||||
it('renders the list page for an authorized user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('shows review packs belonging to the active tenant', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $otherTenant->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertCanSeeTableRecords([$pack]);
|
||||
});
|
||||
|
||||
it('displays the empty state when no packs exist', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertSee('No review packs yet');
|
||||
});
|
||||
|
||||
// ─── List Page Header Action ─────────────────────────────────
|
||||
|
||||
it('shows the generate_pack header action for a MANAGE user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack');
|
||||
});
|
||||
|
||||
it('disables the generate_pack action for a readonly user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertActionVisible('generate_pack')
|
||||
->assertActionDisabled('generate_pack')
|
||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
it('reuses an existing ready pack instead of starting a new run', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$operationRunsBefore = OperationRun::query()->count();
|
||||
$reviewPacksBefore = ReviewPack::query()->count();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->callAction('generate_pack', [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect(OperationRun::query()->count())->toBe($operationRunsBefore);
|
||||
expect(ReviewPack::query()->count())->toBe($reviewPacksBefore);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
// ─── Table Row Actions ───────────────────────────────────────
|
||||
|
||||
it('shows the download action for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertTableActionVisible('download', $pack);
|
||||
});
|
||||
|
||||
it('shows the expire action for a ready pack with confirmation', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/expire-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-fake');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->assertTableActionVisible('expire', $pack)
|
||||
->callTableAction('expire', $pack);
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Expired->value);
|
||||
Storage::disk('exports')->assertMissing($filePath);
|
||||
});
|
||||
|
||||
// ─── View Page ───────────────────────────────────────────────
|
||||
|
||||
it('renders the view page for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => [
|
||||
'finding_count' => 5,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 12,
|
||||
'data_freshness' => [
|
||||
'permission_posture' => now()->toIso8601String(),
|
||||
'entra_admin_roles' => now()->toIso8601String(),
|
||||
'findings' => now()->toIso8601String(),
|
||||
'hardening' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
'options' => ['include_pii' => true, 'include_operations' => true],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('shows download header action on view page for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||
->assertActionVisible('download');
|
||||
});
|
||||
|
||||
it('shows regenerate header action on view page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||
->assertActionVisible('regenerate');
|
||||
});
|
||||
|
||||
// ─── Non-Member Access ───────────────────────────────────────
|
||||
|
||||
it('returns 404 for non-members on list page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('index', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on view page', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
41
tests/Feature/ReviewPack/ReviewPackScheduleTest.php
Normal file
41
tests/Feature/ReviewPack/ReviewPackScheduleTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
it('schedules review pack prune command daily without overlapping', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => str_contains($event->command ?? '', 'tenantpilot:review-pack:prune'));
|
||||
|
||||
expect($event)->not->toBeNull('review-pack:prune should be scheduled');
|
||||
expect($event->withoutOverlapping)->toBeTrue();
|
||||
});
|
||||
|
||||
it('schedules entra admin roles scan daily without overlapping', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => ($event->description ?? null) === 'entra-admin-roles:scan');
|
||||
|
||||
expect($event)->not->toBeNull('entra-admin-roles:scan should be scheduled');
|
||||
expect($event->withoutOverlapping)->toBeTrue();
|
||||
});
|
||||
|
||||
it('documents posture:dispatch deferral', function (): void {
|
||||
// FR-015: tenantpilot:posture:dispatch command infrastructure does not yet exist.
|
||||
// The schedule entry will be added once the command is created.
|
||||
// See specs/109-review-pack-export/research.md §7 for rationale.
|
||||
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => str_contains($event->command ?? '', 'posture:dispatch'));
|
||||
|
||||
expect($event)->toBeNull('posture:dispatch not yet implemented — deferred per FR-015');
|
||||
})->skip('posture:dispatch command deferred — see specs/109 research.md §7');
|
||||
155
tests/Feature/ReviewPack/ReviewPackWidgetTest.php
Normal file
155
tests/Feature/ReviewPack/ReviewPackWidgetTest.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
// ─── No Pack State ───────────────────────────────────────────
|
||||
|
||||
it('shows the generate CTA when no pack exists', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('No review pack generated yet')
|
||||
->assertSee('Generate');
|
||||
});
|
||||
|
||||
// ─── Ready State ─────────────────────────────────────────────
|
||||
|
||||
it('shows download and generate buttons when a ready pack exists', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$filePath = 'review-packs/widget-test.zip';
|
||||
Storage::disk('exports')->put($filePath, 'PK-test');
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Download')
|
||||
->assertSee('Generate new');
|
||||
});
|
||||
|
||||
// ─── Generating State ────────────────────────────────────────
|
||||
|
||||
it('shows in-progress message for a generating pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->generating()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Generation in progress');
|
||||
});
|
||||
|
||||
// ─── Queued State ────────────────────────────────────────────
|
||||
|
||||
it('shows in-progress message for a queued pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->queued()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Generation in progress');
|
||||
});
|
||||
|
||||
// ─── Failed State ────────────────────────────────────────────
|
||||
|
||||
it('shows retry button for a failed pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->failed()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Retry');
|
||||
});
|
||||
|
||||
// ─── Expired State ───────────────────────────────────────────
|
||||
|
||||
it('shows generate action for an expired pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
ReviewPack::factory()->expired()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Generate new');
|
||||
});
|
||||
|
||||
// ─── Generate Pack Livewire Action ──────────────────────────
|
||||
|
||||
it('can trigger generatePack Livewire action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->call('generatePack', true, true)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user