feat: 109 Phase 1+2 — migration, model, factory, enums, badges, config
This commit is contained in:
parent
80f96df3d2
commit
99081b3938
133
app/Models/ReviewPack.php
Normal file
133
app/Models/ReviewPack.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReviewPack extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
public const string STATUS_QUEUED = 'queued';
|
||||
|
||||
public const string STATUS_GENERATING = 'generating';
|
||||
|
||||
public const string STATUS_READY = 'ready';
|
||||
|
||||
public const string STATUS_FAILED = 'failed';
|
||||
|
||||
public const string STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'summary' => 'array',
|
||||
'options' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<OperationRun, $this>
|
||||
*/
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeReady(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_READY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopePastRetention(Builder $query): Builder
|
||||
{
|
||||
return $query->where('expires_at', '<', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeLatestReady(Builder $query): Builder
|
||||
{
|
||||
return $query->ready()->latest('generated_at');
|
||||
}
|
||||
|
||||
public function isReady(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_READY;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED;
|
||||
}
|
||||
|
||||
public function getStatusEnum(): ReviewPackStatus
|
||||
{
|
||||
return ReviewPackStatus::from($this->status);
|
||||
}
|
||||
}
|
||||
@ -109,6 +109,11 @@ class Capabilities
|
||||
|
||||
public const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
|
||||
|
||||
// Review packs
|
||||
public const REVIEW_PACK_VIEW = 'review_pack.view';
|
||||
|
||||
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
|
||||
|
||||
/**
|
||||
* Get all capability constants
|
||||
*
|
||||
|
||||
@ -41,6 +41,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -33,4 +33,5 @@ enum BadgeDomain: string
|
||||
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
}
|
||||
|
||||
27
app/Support/Badges/Domains/ReviewPackStatusBadge.php
Normal file
27
app/Support/Badges/Domains/ReviewPackStatusBadge.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\ReviewPackStatus;
|
||||
|
||||
final class ReviewPackStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
ReviewPackStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
ReviewPackStatus::Generating->value => new BadgeSpec('Generating', 'info', 'heroicon-m-arrow-path'),
|
||||
ReviewPackStatus::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
|
||||
ReviewPackStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
ReviewPackStatus::Expired->value => new BadgeSpec('Expired', 'gray', 'heroicon-m-archive-box'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
14
app/Support/ReviewPackStatus.php
Normal file
14
app/Support/ReviewPackStatus.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
enum ReviewPackStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Generating = 'generating';
|
||||
case Ready = 'ready';
|
||||
case Failed = 'failed';
|
||||
case Expired = 'expired';
|
||||
}
|
||||
@ -60,6 +60,13 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'exports' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private/exports'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -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),
|
||||
],
|
||||
];
|
||||
|
||||
133
database/factories/ReviewPackFactory.php
Normal file
133
database/factories/ReviewPackFactory.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ReviewPack>
|
||||
*/
|
||||
class ReviewPackFactory extends Factory
|
||||
{
|
||||
protected $model = ReviewPack::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'initiated_by_user_id' => User::factory(),
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'previous_fingerprint' => null,
|
||||
'summary' => [
|
||||
'finding_count' => fake()->numberBetween(0, 100),
|
||||
'report_count' => fake()->numberBetween(0, 10),
|
||||
],
|
||||
'options' => [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => fn () => 'review-packs/'.fake()->uuid().'.zip',
|
||||
'file_size' => fake()->numberBetween(1024, 1048576),
|
||||
'sha256' => fake()->sha256(),
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
];
|
||||
}
|
||||
|
||||
public function queued(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function generating(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Generating->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ready(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => 'review-packs/'.fake()->uuid().'.zip',
|
||||
'file_size' => fake()->numberBetween(1024, 1048576),
|
||||
'sha256' => fake()->sha256(),
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Failed->value,
|
||||
'fingerprint' => null,
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function expired(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => ReviewPackStatus::Expired->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'file_disk' => null,
|
||||
'file_path' => null,
|
||||
'file_size' => null,
|
||||
'sha256' => null,
|
||||
'generated_at' => now()->subDays(91),
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_packs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('status')->default('queued');
|
||||
$table->string('fingerprint', 64)->nullable();
|
||||
$table->string('previous_fingerprint', 64)->nullable();
|
||||
$table->jsonb('summary')->default('{}');
|
||||
$table->jsonb('options')->default('{}');
|
||||
$table->string('file_disk')->nullable();
|
||||
$table->string('file_path')->nullable();
|
||||
$table->bigInteger('file_size')->nullable();
|
||||
$table->string('sha256', 64)->nullable();
|
||||
$table->timestampTz('generated_at')->nullable();
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'tenant_id', 'generated_at']);
|
||||
$table->index(['status', 'expires_at']);
|
||||
});
|
||||
|
||||
DB::statement('
|
||||
CREATE UNIQUE INDEX review_packs_fingerprint_unique
|
||||
ON review_packs (workspace_id, tenant_id, fingerprint)
|
||||
WHERE fingerprint IS NOT NULL AND status NOT IN (\'expired\', \'failed\')
|
||||
');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_packs');
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user