Some checks failed
Main Confidence / confidence (push) Failing after 53s
Automated commit and PR created by Copilot per user request. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #287
424 lines
16 KiB
PHP
424 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
|
use App\Jobs\GenerateReviewPackJob;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\User;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
use App\Services\Evidence\EvidenceResolutionRequest;
|
|
use App\Services\Evidence\EvidenceSnapshotResolver;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
|
use App\Support\ReviewPackStatus;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
class ReviewPackService
|
|
{
|
|
public function __construct(
|
|
private OperationRunService $operationRunService,
|
|
private EvidenceSnapshotResolver $snapshotResolver,
|
|
private WorkspaceAuditLogger $auditLogger,
|
|
private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
|
private ProductTelemetryRecorder $productTelemetryRecorder,
|
|
) {}
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const REQUIRED_EVIDENCE_DIMENSIONS = [
|
|
'findings_summary',
|
|
'permission_posture',
|
|
'entra_admin_roles',
|
|
'baseline_drift_posture',
|
|
'operations_summary',
|
|
];
|
|
|
|
/**
|
|
* Create an OperationRun + ReviewPack and dispatch the generation job.
|
|
*
|
|
* @param array<string, mixed> $options
|
|
*/
|
|
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
|
{
|
|
$this->assertReviewPackGenerationAllowed($tenant);
|
|
|
|
$options = $this->normalizeOptions($options);
|
|
$snapshot = $this->resolveSnapshot($tenant);
|
|
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
|
|
|
$existing = $this->findExistingPack($tenant, $fingerprint);
|
|
|
|
if ($existing instanceof ReviewPack) {
|
|
$this->recordReviewPackRequestTelemetry($existing, $user, 'tenant');
|
|
|
|
return $existing;
|
|
}
|
|
|
|
$operationRun = $this->operationRunService->ensureRun(
|
|
tenant: $tenant,
|
|
type: OperationRunType::ReviewPackGenerate->value,
|
|
inputs: [
|
|
'include_pii' => $options['include_pii'],
|
|
'include_operations' => $options['include_operations'],
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
],
|
|
initiator: $user,
|
|
);
|
|
|
|
if (! $operationRun->wasRecentlyCreated) {
|
|
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
|
|
|
if ($queuedPack instanceof ReviewPack) {
|
|
$this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant');
|
|
|
|
return $queuedPack;
|
|
}
|
|
}
|
|
|
|
$reviewPack = ReviewPack::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'status' => ReviewPackStatus::Queued->value,
|
|
'options' => $options,
|
|
'summary' => [
|
|
'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null)
|
|
? $snapshot->summary['finding_outcomes']
|
|
: [],
|
|
'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null)
|
|
? $snapshot->summary['finding_report_buckets']
|
|
: [],
|
|
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
|
|
? $snapshot->summary['risk_acceptance']
|
|
: [],
|
|
'evidence_resolution' => [
|
|
'outcome' => 'resolved',
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
|
GenerateReviewPackJob::dispatch(
|
|
reviewPackId: (int) $reviewPack->getKey(),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
);
|
|
});
|
|
|
|
$this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant');
|
|
|
|
return $reviewPack;
|
|
}
|
|
|
|
/**
|
|
* Create a review-derived executive pack.
|
|
*
|
|
* @param array<string, mixed> $options
|
|
*/
|
|
public function generateFromReview(TenantReview $review, User $user, array $options = []): ReviewPack
|
|
{
|
|
$review->loadMissing(['tenant', 'evidenceSnapshot', 'sections']);
|
|
|
|
$tenant = $review->tenant;
|
|
$snapshot = $review->evidenceSnapshot;
|
|
|
|
if (! $tenant instanceof Tenant || ! $snapshot instanceof EvidenceSnapshot) {
|
|
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
|
}
|
|
|
|
$this->assertReviewPackGenerationAllowed($tenant);
|
|
|
|
$options = $this->normalizeOptions($options);
|
|
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
|
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
|
|
|
if ($existing instanceof ReviewPack) {
|
|
$this->logReviewExport($review, $user, $existing, 'reused');
|
|
$this->recordReviewPackRequestTelemetry($existing, $user, 'tenant_review');
|
|
|
|
return $existing;
|
|
}
|
|
|
|
$operationRun = $this->operationRunService->ensureRun(
|
|
tenant: $tenant,
|
|
type: OperationRunType::ReviewPackGenerate->value,
|
|
inputs: [
|
|
'tenant_review_id' => (int) $review->getKey(),
|
|
'include_pii' => $options['include_pii'],
|
|
'include_operations' => $options['include_operations'],
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
],
|
|
initiator: $user,
|
|
);
|
|
|
|
if (! $operationRun->wasRecentlyCreated) {
|
|
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
|
|
|
if ($queuedPack instanceof ReviewPack) {
|
|
$this->logReviewExport($review, $user, $queuedPack, 'reused_active_run');
|
|
$this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant_review');
|
|
|
|
return $queuedPack;
|
|
}
|
|
}
|
|
|
|
$reviewPack = ReviewPack::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_review_id' => (int) $review->getKey(),
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'status' => ReviewPackStatus::Queued->value,
|
|
'options' => $options,
|
|
'summary' => [
|
|
'tenant_review_id' => (int) $review->getKey(),
|
|
'review_status' => (string) $review->status,
|
|
'review_completeness_state' => (string) $review->completeness_state,
|
|
'section_count' => $review->sections->count(),
|
|
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
|
? $review->summary['finding_outcomes']
|
|
: [],
|
|
'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null)
|
|
? $review->summary['finding_report_buckets']
|
|
: [],
|
|
'evidence_resolution' => [
|
|
'outcome' => 'resolved',
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
|
GenerateReviewPackJob::dispatch(
|
|
reviewPackId: (int) $reviewPack->getKey(),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
);
|
|
});
|
|
|
|
$this->logReviewExport($review, $user, $reviewPack, 'queued');
|
|
$this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant_review');
|
|
|
|
return $reviewPack;
|
|
}
|
|
|
|
/**
|
|
* Compute a deterministic fingerprint for deduplication.
|
|
*
|
|
* @param array<string, mixed> $options
|
|
*/
|
|
public function computeFingerprint(Tenant $tenant, array $options): string
|
|
{
|
|
return $this->computeFingerprintForSnapshot($this->resolveSnapshot($tenant), $this->normalizeOptions($options));
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
|
|
{
|
|
return $this->workspaceEntitlementResolver->resolve(
|
|
$tenant->workspace,
|
|
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
|
);
|
|
}
|
|
|
|
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
|
|
{
|
|
$this->productTelemetryRecorder->record(
|
|
eventName: ProductUsageEventCatalog::REVIEW_PACK_REQUESTED,
|
|
workspaceId: (int) $reviewPack->workspace_id,
|
|
tenantId: (int) $reviewPack->tenant_id,
|
|
userId: (int) $user->getKey(),
|
|
subjectType: 'review_pack',
|
|
subjectId: (int) $reviewPack->getKey(),
|
|
metadata: [
|
|
'source_surface' => $sourceSurface,
|
|
'include_operations' => (bool) ($reviewPack->options['include_operations'] ?? false),
|
|
'include_pii' => (bool) ($reviewPack->options['include_pii'] ?? false),
|
|
],
|
|
occurredAt: $reviewPack->created_at ?? now(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
public function findExistingPackForReview(TenantReview $review, string $fingerprint): ?ReviewPack
|
|
{
|
|
return ReviewPack::query()
|
|
->where('tenant_review_id', (int) $review->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();
|
|
}
|
|
|
|
public function checkActiveRunForReview(TenantReview $review): bool
|
|
{
|
|
return OperationRun::query()
|
|
->where('tenant_id', (int) $review->tenant_id)
|
|
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
->whereJsonContains('context->tenant_review_id', (int) $review->getKey())
|
|
->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)),
|
|
];
|
|
}
|
|
|
|
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
|
|
{
|
|
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
|
|
|
|
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
|
return;
|
|
}
|
|
|
|
throw new WorkspaceEntitlementBlockedException($decision);
|
|
}
|
|
|
|
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
|
{
|
|
$data = [
|
|
'tenant_id' => (int) $snapshot->tenant_id,
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'evidence_fingerprint' => (string) $snapshot->fingerprint,
|
|
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
|
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
|
];
|
|
|
|
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
public function computeFingerprintForReview(TenantReview $review, array $options): string
|
|
{
|
|
$data = [
|
|
'tenant_review_id' => (int) $review->getKey(),
|
|
'review_fingerprint' => (string) $review->fingerprint,
|
|
'review_status' => (string) $review->status,
|
|
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
|
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
|
];
|
|
|
|
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
private function resolveSnapshot(Tenant $tenant): EvidenceSnapshot
|
|
{
|
|
$result = $this->snapshotResolver->resolve(new EvidenceResolutionRequest(
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
tenantId: (int) $tenant->getKey(),
|
|
requiredDimensions: self::REQUIRED_EVIDENCE_DIMENSIONS,
|
|
));
|
|
|
|
if (! $result->isResolved()) {
|
|
throw new ReviewPackEvidenceResolutionException($result);
|
|
}
|
|
|
|
return $result->snapshot;
|
|
}
|
|
|
|
private function findPackForRun(Tenant $tenant, OperationRun $operationRun): ?ReviewPack
|
|
{
|
|
return ReviewPack::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('operation_run_id', (int) $operationRun->getKey())
|
|
->latest('id')
|
|
->first();
|
|
}
|
|
|
|
private function logReviewExport(TenantReview $review, User $user, ReviewPack $reviewPack, string $mode): void
|
|
{
|
|
$tenant = $review->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $tenant->workspace,
|
|
action: AuditActionId::TenantReviewExported,
|
|
context: [
|
|
'metadata' => [
|
|
'review_id' => (int) $review->getKey(),
|
|
'review_pack_id' => (int) $reviewPack->getKey(),
|
|
'mode' => $mode,
|
|
'status' => (string) $reviewPack->status,
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'tenant_review',
|
|
resourceId: (string) $review->getKey(),
|
|
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
|
operationRunId: $reviewPack->operation_run_id,
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
}
|