feat(109): complete Review Pack Export v1 — Phases 3-8

- ReviewPackService: generate, fingerprint dedupe, signed download URL
- GenerateReviewPackJob: 12-step pipeline, ZIP assembly, failure handling
- ReviewPackDownloadController: signed URL streaming with SHA-256 header
- ReviewPackResource: list/view pages, generate/expire/download actions
- TenantReviewPackCard: dashboard widget with 5 display states
- ReviewPackPolicy: RBAC via REVIEW_PACK_VIEW/MANAGE capabilities
- PruneReviewPacksCommand: retention automation + hard-delete option
- ReviewPackStatusNotification: database channel, ready/failed payloads
- Schedule: daily prune + entra admin roles, posture:dispatch deferred
- AlertRuleResource: hide sla_due from dropdown (backward compat kept)
- 59 passing tests across 7 test files (1 skipped: posture deferred)
- All 36 tasks completed per tasks.md
This commit is contained in:
Ahmed Darrazi 2026-02-23 11:00:47 +01:00
parent 99081b3938
commit 2a1a708716
23 changed files with 3113 additions and 37 deletions

View 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();
}
}

View File

@ -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)',
];

View File

@ -0,0 +1,333 @@
<?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\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 = 'Monitoring';
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.');
}
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),
];
$service->generate($tenant, $user, $options);
Notification::make()->success()->title('Review pack generation started.')->send();
}
}

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View 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,
];
}
}

View 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 ?? '',
]);
}
}

View File

@ -0,0 +1,419 @@
<?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\Notifications\ReviewPackStatusNotification;
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(): 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, 'tenant_not_found', 'Tenant not found');
return;
}
// Mark running
$operationRun->update([
'status' => OperationRunStatus::Running->value,
'started_at' => now(),
]);
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
try {
$this->executeGeneration($reviewPack, $operationRun, $tenant);
} catch (Throwable $e) {
$this->markFailed($reviewPack, $operationRun, 'generation_error', $e->getMessage());
throw $e;
}
}
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant): 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
$operationRun->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'summary_counts' => $summary,
]);
// 14. Notify initiator
$this->notifyInitiator($reviewPack, 'ready');
}
/**
* @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, string $reasonCode, string $errorMessage): void
{
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
$operationRun->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
'context' => array_merge($operationRun->context ?? [], [
'reason_code' => $reasonCode,
'error_message' => mb_substr($errorMessage, 0, 500),
]),
]);
$this->notifyInitiator($reviewPack, 'failed', $reasonCode);
}
private function notifyInitiator(ReviewPack $reviewPack, string $status, ?string $reasonCode = null): void
{
$initiator = $reviewPack->initiator;
if (! $initiator) {
return;
}
try {
$initiator->notify(new ReviewPackStatusNotification($reviewPack, $status, $reasonCode));
} catch (Throwable $e) {
Log::warning('Failed to send ReviewPack notification', [
'review_pack_id' => $reviewPack->getKey(),
'error' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\ReviewPack;
use App\Models\Tenant;
use Filament\Actions\Action;
use Illuminate\Notifications\Notification;
class ReviewPackStatusNotification extends Notification
{
public function __construct(
public ReviewPack $reviewPack,
public string $status,
public ?string $reasonCode = null,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
$title = match ($this->status) {
'ready' => 'Review Pack ready',
'failed' => 'Review Pack generation failed',
default => 'Review Pack status updated',
};
$body = match ($this->status) {
'ready' => 'Your tenant review pack has been generated and is ready for download.',
'failed' => sprintf(
'Review pack generation failed%s.',
$this->reasonCode ? ": {$this->reasonCode}" : '',
),
default => 'Review pack status changed.',
};
$color = match ($this->status) {
'ready' => 'success',
'failed' => 'danger',
default => 'gray',
};
$icon = match ($this->status) {
'ready' => 'heroicon-o-document-arrow-down',
'failed' => 'heroicon-o-exclamation-triangle',
default => 'heroicon-o-document-text',
};
$actions = [];
$tenant = $this->reviewPack->tenant;
if ($tenant instanceof Tenant && $this->status === 'ready') {
$actions[] = Action::make('view_pack')
->label('View pack')
->url(route('filament.admin.resources.review-packs.view', [
'tenant' => $tenant->external_id,
'record' => $this->reviewPack->getKey(),
]))
->toArray();
}
return [
'format' => 'filament',
'title' => $title,
'body' => $body,
'color' => $color,
'duration' => 'persistent',
'actions' => $actions,
'icon' => $icon,
'iconColor' => $color,
'status' => null,
'view' => null,
'viewData' => [
'review_pack_id' => $this->reviewPack->getKey(),
'status' => $this->status,
'reason_code' => $this->reasonCode,
],
];
}
}

View 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);
}
}

View File

@ -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,
],
];

View File

@ -0,0 +1,151 @@
<?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' => [],
]);
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)),
];
}
}

View File

@ -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&hellip;
</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>

View File

@ -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).

View File

@ -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) {

View File

@ -31,7 +31,7 @@ ## Phase 1: Setup
**Purpose**: Database schema — must complete before model/factory creation
- [ ] 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`
- [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`
---
@ -41,16 +41,16 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [ ] T002 [P] Create `ReviewPackStatus` enum with 5 string-backed cases (Queued, Generating, Ready, Failed, Expired) in `app/Support/ReviewPackStatus.php`
- [ ] T003 [P] Add `ReviewPackGenerate` case with value `tenant.review_pack.generate` to `app/Support/OperationRunType.php`
- [ ] T004 [P] Add `REVIEW_PACK_VIEW = 'review_pack.view'` and `REVIEW_PACK_MANAGE = 'review_pack.manage'` constants to `app/Support/Auth/Capabilities.php`
- [ ] T005 [P] Add `ReviewPackStatus = 'review_pack_status'` case to `app/Support/Badges/BadgeDomain.php`
- [ ] 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`
- [ ] T007 [P] Add `exports` disk with local driver at `storage_path('app/private/exports')`, `serve => false`, `throw => true` to `config/filesystems.php`
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] T011 Run migration and verify model + factory (`vendor/bin/sail artisan migrate && vendor/bin/sail artisan test --compact --filter=ReviewPack`)
- [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
@ -64,19 +64,19 @@ ## Phase 3: User Story 1 — Generate and Download Review Pack (Priority: P1)
### Implementation for User Story 1
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] T016 [US1] Add signed download route `GET /admin/review-packs/{reviewPack}/download` named `admin.review-packs.download` with `signed` middleware in `routes/web.php`
- [ ] 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`
- [ ] T018 [US1] Create `ListReviewPacks` page extending ListRecords in `app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [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
@ -90,7 +90,7 @@ ## Phase 4: User Story 2 — Fingerprint Dedupe (Priority: P2)
### Implementation for User Story 2
- [ ] 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`
- [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
@ -104,8 +104,8 @@ ## Phase 5: User Story 3 — RBAC Enforcement (Priority: P2)
### Implementation for User Story 3
- [ ] 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`
- [ ] 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`
- [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)
@ -119,10 +119,10 @@ ## Phase 6: User Story 4 — Retention & Prune (Priority: P3)
### Implementation for User Story 4
- [ ] 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`
- [ ] T029 [US4] Add prune command schedule entry `tenantpilot:review-pack:prune` with `daily()` + `withoutOverlapping()` in `routes/console.php`
- [ ] 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`
- [ ] 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`
- [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
@ -136,8 +136,8 @@ ## Phase 7: User Story 5 — Scheduled Scan Wiring (Priority: P3)
### Implementation for User Story 5
- [ ] 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)
- [ ] 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`
- [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
@ -147,9 +147,9 @@ ## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Code quality, formatting, and end-to-end validation
- [ ] T034 [P] Run Pint formatter on all new and modified files (`vendor/bin/sail bin pint --dirty --format agent`)
- [ ] T035 Run full ReviewPack test suite and fix any failures (`vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/`)
- [ ] 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
- [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
---

View 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();
});

View File

@ -0,0 +1,415 @@
<?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\ReviewPackStatusNotification;
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,
);
$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
Notification::assertSentTo($user, ReviewPackStatusNotification::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 {
$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->context['reason_code'])->toBe('generation_error');
Notification::assertSentTo($user, ReviewPackStatusNotification::class, function ($notification) {
return $notification->status === 'failed';
});
});
// ─── 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,
);
$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,
);
$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,
);
$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();
});
});
// ─── 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);
});

View 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);
});

View 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();
});

View File

@ -0,0 +1,241 @@
<?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\ReviewPack;
use App\Models\Tenant;
use App\Support\Auth\UiTooltips;
use App\Support\ReviewPackStatus;
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');
});
// ─── 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());
});
// ─── 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();
});

View 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');

View 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);
});