feat: 109 Phase 1+2 — migration, model, factory, enums, badges, config

This commit is contained in:
Ahmed Darrazi 2026-02-23 10:13:40 +01:00
parent 80f96df3d2
commit 99081b3938
11 changed files with 376 additions and 0 deletions

133
app/Models/ReviewPack.php Normal file
View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\ReviewPackStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReviewPack extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string STATUS_QUEUED = 'queued';
public const string STATUS_GENERATING = 'generating';
public const string STATUS_READY = 'ready';
public const string STATUS_FAILED = 'failed';
public const string STATUS_EXPIRED = 'expired';
protected $guarded = [];
protected function casts(): array
{
return [
'summary' => 'array',
'options' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
'file_size' => 'integer',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<OperationRun, $this>
*/
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by_user_id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeReady(Builder $query): Builder
{
return $query->where('status', self::STATUS_READY);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeExpired(Builder $query): Builder
{
return $query->where('status', self::STATUS_EXPIRED);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopePastRetention(Builder $query): Builder
{
return $query->where('expires_at', '<', now());
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForTenant(Builder $query, int $tenantId): Builder
{
return $query->where('tenant_id', $tenantId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeLatestReady(Builder $query): Builder
{
return $query->ready()->latest('generated_at');
}
public function isReady(): bool
{
return $this->status === self::STATUS_READY;
}
public function isExpired(): bool
{
return $this->status === self::STATUS_EXPIRED;
}
public function getStatusEnum(): ReviewPackStatus
{
return ReviewPackStatus::from($this->status);
}
}

View File

@ -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
*

View File

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

View File

@ -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';
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\ReviewPackStatus;
final class ReviewPackStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
ReviewPackStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
ReviewPackStatus::Generating->value => new BadgeSpec('Generating', 'info', 'heroicon-m-arrow-path'),
ReviewPackStatus::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
ReviewPackStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
ReviewPackStatus::Expired->value => new BadgeSpec('Expired', 'gray', 'heroicon-m-archive-box'),
default => BadgeSpec::unknown(),
};
}
}

View File

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

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support;
enum ReviewPackStatus: string
{
case Queued = 'queued';
case Generating = 'generating';
case Ready = 'ready';
case Failed = 'failed';
case Expired = 'expired';
}

View File

@ -60,6 +60,13 @@
'report' => false,
],
'exports' => [
'driver' => 'local',
'root' => storage_path('app/private/exports'),
'serve' => false,
'throw' => true,
],
],
/*

View File

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

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ReviewPackStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ReviewPack>
*/
class ReviewPackFactory extends Factory
{
protected $model = ReviewPack::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
'workspace_id' => function (array $attributes): int {
$tenantId = $attributes['tenant_id'] ?? null;
if (! is_numeric($tenantId)) {
return (int) Workspace::factory()->create()->getKey();
}
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
return (int) Workspace::factory()->create()->getKey();
}
return (int) $tenant->workspace_id;
},
'initiated_by_user_id' => User::factory(),
'status' => ReviewPackStatus::Ready->value,
'fingerprint' => fake()->sha256(),
'previous_fingerprint' => null,
'summary' => [
'finding_count' => fake()->numberBetween(0, 100),
'report_count' => fake()->numberBetween(0, 10),
],
'options' => [
'include_pii' => true,
'include_operations' => true,
],
'file_disk' => 'exports',
'file_path' => fn () => 'review-packs/'.fake()->uuid().'.zip',
'file_size' => fake()->numberBetween(1024, 1048576),
'sha256' => fake()->sha256(),
'generated_at' => now(),
'expires_at' => now()->addDays(90),
];
}
public function queued(): static
{
return $this->state(fn (array $attributes): array => [
'status' => ReviewPackStatus::Queued->value,
'fingerprint' => null,
'file_disk' => null,
'file_path' => null,
'file_size' => null,
'sha256' => null,
'generated_at' => null,
'expires_at' => null,
]);
}
public function generating(): static
{
return $this->state(fn (array $attributes): array => [
'status' => ReviewPackStatus::Generating->value,
'fingerprint' => null,
'file_disk' => null,
'file_path' => null,
'file_size' => null,
'sha256' => null,
'generated_at' => null,
'expires_at' => null,
]);
}
public function ready(): static
{
return $this->state(fn (array $attributes): array => [
'status' => ReviewPackStatus::Ready->value,
'fingerprint' => fake()->sha256(),
'file_disk' => 'exports',
'file_path' => 'review-packs/'.fake()->uuid().'.zip',
'file_size' => fake()->numberBetween(1024, 1048576),
'sha256' => fake()->sha256(),
'generated_at' => now(),
'expires_at' => now()->addDays(90),
]);
}
public function failed(): static
{
return $this->state(fn (array $attributes): array => [
'status' => ReviewPackStatus::Failed->value,
'fingerprint' => null,
'file_disk' => null,
'file_path' => null,
'file_size' => null,
'sha256' => null,
'generated_at' => null,
'expires_at' => null,
]);
}
public function expired(): static
{
return $this->state(fn (array $attributes): array => [
'status' => ReviewPackStatus::Expired->value,
'fingerprint' => fake()->sha256(),
'file_disk' => null,
'file_path' => null,
'file_size' => null,
'sha256' => null,
'generated_at' => now()->subDays(91),
'expires_at' => now()->subDay(),
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('review_packs', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('status')->default('queued');
$table->string('fingerprint', 64)->nullable();
$table->string('previous_fingerprint', 64)->nullable();
$table->jsonb('summary')->default('{}');
$table->jsonb('options')->default('{}');
$table->string('file_disk')->nullable();
$table->string('file_path')->nullable();
$table->bigInteger('file_size')->nullable();
$table->string('sha256', 64)->nullable();
$table->timestampTz('generated_at')->nullable();
$table->timestampTz('expires_at')->nullable();
$table->timestamps();
$table->index(['workspace_id', 'tenant_id', 'generated_at']);
$table->index(['status', 'expires_at']);
});
DB::statement('
CREATE UNIQUE INDEX review_packs_fingerprint_unique
ON review_packs (workspace_id, tenant_id, fingerprint)
WHERE fingerprint IS NOT NULL AND status NOT IN (\'expired\', \'failed\')
');
}
public function down(): void
{
Schema::dropIfExists('review_packs');
}
};