- 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
152 lines
4.9 KiB
PHP
152 lines
4.9 KiB
PHP
<?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)),
|
|
];
|
|
}
|
|
}
|