diff --git a/app/Models/ReviewPack.php b/app/Models/ReviewPack.php new file mode 100644 index 0000000..60b9fad --- /dev/null +++ b/app/Models/ReviewPack.php @@ -0,0 +1,133 @@ + 'array', + 'options' => 'array', + 'generated_at' => 'datetime', + 'expires_at' => 'datetime', + 'file_size' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class); + } + + /** + * @return BelongsTo + */ + public function initiator(): BelongsTo + { + return $this->belongsTo(User::class, 'initiated_by_user_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeReady(Builder $query): Builder + { + return $query->where('status', self::STATUS_READY); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeExpired(Builder $query): Builder + { + return $query->where('status', self::STATUS_EXPIRED); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePastRetention(Builder $query): Builder + { + return $query->where('expires_at', '<', now()); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForTenant(Builder $query, int $tenantId): Builder + { + return $query->where('tenant_id', $tenantId); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeLatestReady(Builder $query): Builder + { + return $query->ready()->latest('generated_at'); + } + + public function isReady(): bool + { + return $this->status === self::STATUS_READY; + } + + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED; + } + + public function getStatusEnum(): ReviewPackStatus + { + return ReviewPackStatus::from($this->status); + } +} diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index aad7d0a..10b3cf2 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -109,6 +109,11 @@ class Capabilities public const ENTRA_ROLES_MANAGE = 'entra_roles.manage'; + // Review packs + public const REVIEW_PACK_VIEW = 'review_pack.view'; + + public const REVIEW_PACK_MANAGE = 'review_pack.manage'; + /** * Get all capability constants * diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index a9a3cbe..c16fb2c 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -41,6 +41,7 @@ final class BadgeCatalog BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class, BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, + BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, ]; /** diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 2fad501..013145e 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -33,4 +33,5 @@ enum BadgeDomain: string case AlertDestinationLastTestStatus = 'alert_destination_last_test_status'; case BaselineProfileStatus = 'baseline_profile_status'; case FindingType = 'finding_type'; + case ReviewPackStatus = 'review_pack_status'; } diff --git a/app/Support/Badges/Domains/ReviewPackStatusBadge.php b/app/Support/Badges/Domains/ReviewPackStatusBadge.php new file mode 100644 index 0000000..c0393a9 --- /dev/null +++ b/app/Support/Badges/Domains/ReviewPackStatusBadge.php @@ -0,0 +1,27 @@ +value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'), + ReviewPackStatus::Generating->value => new BadgeSpec('Generating', 'info', 'heroicon-m-arrow-path'), + ReviewPackStatus::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'), + ReviewPackStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), + ReviewPackStatus::Expired->value => new BadgeSpec('Expired', 'gray', 'heroicon-m-archive-box'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/OperationRunType.php b/app/Support/OperationRunType.php index 9f477f5..04b3f11 100644 --- a/app/Support/OperationRunType.php +++ b/app/Support/OperationRunType.php @@ -17,6 +17,7 @@ enum OperationRunType: string case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'; case RestoreExecute = 'restore.execute'; case EntraAdminRolesScan = 'entra.admin_roles.scan'; + case ReviewPackGenerate = 'tenant.review_pack.generate'; public static function values(): array { diff --git a/app/Support/ReviewPackStatus.php b/app/Support/ReviewPackStatus.php new file mode 100644 index 0000000..8ea63b0 --- /dev/null +++ b/app/Support/ReviewPackStatus.php @@ -0,0 +1,14 @@ + false, ], + 'exports' => [ + 'driver' => 'local', + 'root' => storage_path('app/private/exports'), + 'serve' => false, + 'throw' => true, + ], + ], /* diff --git a/config/tenantpilot.php b/config/tenantpilot.php index f41f7d0..264712a 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -357,4 +357,12 @@ 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), ], + + 'review_pack' => [ + 'retention_days' => (int) env('TENANTPILOT_REVIEW_PACK_RETENTION_DAYS', 90), + 'hard_delete_grace_days' => (int) env('TENANTPILOT_REVIEW_PACK_HARD_DELETE_GRACE_DAYS', 30), + 'download_url_ttl_minutes' => (int) env('TENANTPILOT_REVIEW_PACK_DOWNLOAD_URL_TTL_MINUTES', 60), + 'include_pii_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_PII_DEFAULT', true), + 'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true), + ], ]; diff --git a/database/factories/ReviewPackFactory.php b/database/factories/ReviewPackFactory.php new file mode 100644 index 0000000..28ca0f4 --- /dev/null +++ b/database/factories/ReviewPackFactory.php @@ -0,0 +1,133 @@ + + */ +class ReviewPackFactory extends Factory +{ + protected $model = ReviewPack::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory()->for(Workspace::factory()), + 'workspace_id' => function (array $attributes): int { + $tenantId = $attributes['tenant_id'] ?? null; + + if (! is_numeric($tenantId)) { + return (int) Workspace::factory()->create()->getKey(); + } + + $tenant = Tenant::query()->whereKey((int) $tenantId)->first(); + + if (! $tenant instanceof Tenant || $tenant->workspace_id === null) { + return (int) Workspace::factory()->create()->getKey(); + } + + return (int) $tenant->workspace_id; + }, + 'initiated_by_user_id' => User::factory(), + 'status' => ReviewPackStatus::Ready->value, + 'fingerprint' => fake()->sha256(), + 'previous_fingerprint' => null, + 'summary' => [ + 'finding_count' => fake()->numberBetween(0, 100), + 'report_count' => fake()->numberBetween(0, 10), + ], + 'options' => [ + 'include_pii' => true, + 'include_operations' => true, + ], + 'file_disk' => 'exports', + 'file_path' => fn () => 'review-packs/'.fake()->uuid().'.zip', + 'file_size' => fake()->numberBetween(1024, 1048576), + 'sha256' => fake()->sha256(), + 'generated_at' => now(), + 'expires_at' => now()->addDays(90), + ]; + } + + public function queued(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReviewPackStatus::Queued->value, + 'fingerprint' => null, + 'file_disk' => null, + 'file_path' => null, + 'file_size' => null, + 'sha256' => null, + 'generated_at' => null, + 'expires_at' => null, + ]); + } + + public function generating(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReviewPackStatus::Generating->value, + 'fingerprint' => null, + 'file_disk' => null, + 'file_path' => null, + 'file_size' => null, + 'sha256' => null, + 'generated_at' => null, + 'expires_at' => null, + ]); + } + + public function ready(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReviewPackStatus::Ready->value, + 'fingerprint' => fake()->sha256(), + 'file_disk' => 'exports', + 'file_path' => 'review-packs/'.fake()->uuid().'.zip', + 'file_size' => fake()->numberBetween(1024, 1048576), + 'sha256' => fake()->sha256(), + 'generated_at' => now(), + 'expires_at' => now()->addDays(90), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReviewPackStatus::Failed->value, + 'fingerprint' => null, + 'file_disk' => null, + 'file_path' => null, + 'file_size' => null, + 'sha256' => null, + 'generated_at' => null, + 'expires_at' => null, + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReviewPackStatus::Expired->value, + 'fingerprint' => fake()->sha256(), + 'file_disk' => null, + 'file_path' => null, + 'file_size' => null, + 'sha256' => null, + 'generated_at' => now()->subDays(91), + 'expires_at' => now()->subDay(), + ]); + } +} diff --git a/database/migrations/2026_02_23_100000_create_review_packs_table.php b/database/migrations/2026_02_23_100000_create_review_packs_table.php new file mode 100644 index 0000000..29b8b29 --- /dev/null +++ b/database/migrations/2026_02_23_100000_create_review_packs_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete(); + $table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('status')->default('queued'); + $table->string('fingerprint', 64)->nullable(); + $table->string('previous_fingerprint', 64)->nullable(); + $table->jsonb('summary')->default('{}'); + $table->jsonb('options')->default('{}'); + $table->string('file_disk')->nullable(); + $table->string('file_path')->nullable(); + $table->bigInteger('file_size')->nullable(); + $table->string('sha256', 64)->nullable(); + $table->timestampTz('generated_at')->nullable(); + $table->timestampTz('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'tenant_id', 'generated_at']); + $table->index(['status', 'expires_at']); + }); + + DB::statement(' + CREATE UNIQUE INDEX review_packs_fingerprint_unique + ON review_packs (workspace_id, tenant_id, fingerprint) + WHERE fingerprint IS NOT NULL AND status NOT IN (\'expired\', \'failed\') + '); + } + + public function down(): void + { + Schema::dropIfExists('review_packs'); + } +};