TenantAtlas/apps/platform/app/Services/ReviewPackService.php
Ahmed Darrazi 54f81d4a50
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 2m1s
feat: review pack pdf and html renderer v1 (spec 356)
Implemented the first version of the PDF and HTML renderer for review packs. Added ReviewPackRenderedReportController and related blade views to render reports. Updated EnvironmentReviewResource, ReviewPackResource, ReviewPackService, and routing. Added new tests for the renderer and download actions, and updated UI documentation.
2026-06-05 22:38:10 +02:00

461 lines
18 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\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
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 const string REVIEW_DERIVED_DELIVERY_CONTRACT = 'auditor_ready_executive_export.v1';
public const string EXECUTIVE_ENTRYPOINT_FILENAME = 'executive-summary.md';
public function __construct(
private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger,
private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver,
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(ManagedEnvironment $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([
'managed_environment_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(EnvironmentReview $review, User $user, array $options = []): ReviewPack
{
$review->loadMissing(['tenant', 'evidenceSnapshot', 'sections']);
$tenant = $review->tenant;
$snapshot = $review->evidenceSnapshot;
if (! $tenant instanceof ManagedEnvironment || ! $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, 'environment_review');
return $existing;
}
$operationRun = $this->operationRunService->ensureRun(
tenant: $tenant,
type: OperationRunType::ReviewPackGenerate->value,
inputs: [
'environment_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, 'environment_review');
return $queuedPack;
}
}
$reviewPack = ReviewPack::create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_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' => [
'environment_review_id' => (int) $review->getKey(),
'review_status' => (string) $review->status,
'review_completeness_state' => (string) $review->completeness_state,
'section_count' => $review->sections->count(),
'delivery_bundle' => [
'contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
'executive_entrypoint_file' => self::EXECUTIVE_ENTRYPOINT_FILENAME,
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
],
'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, 'environment_review');
return $reviewPack;
}
/**
* Compute a deterministic fingerprint for deduplication.
*
* @param array<string, mixed> $options
*/
public function computeFingerprint(ManagedEnvironment $tenant, array $options): string
{
return $this->computeFingerprintForSnapshot($this->resolveSnapshot($tenant), $this->normalizeOptions($options));
}
/**
* Generate a signed download URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.download',
array_merge(['reviewPack' => $pack->getKey()], $parameters),
now()->addMinutes($ttlMinutes),
);
}
/**
* Generate a signed rendered-report URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/
public function generateRenderedReportUrl(ReviewPack $pack, array $parameters = []): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.report',
array_merge(['reviewPack' => $pack->getKey()], $parameters),
now()->addMinutes($ttlMinutes),
);
}
/**
* @return array<string, mixed>
*/
public function reviewPackGenerationDecisionForTenant(ManagedEnvironment $tenant): array
{
$tenant->loadMissing('workspace');
$decision = $this->workspaceCommercialLifecycleResolver->reviewPackStartDecisionForTenant($tenant);
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
? $decision['entitlement_decision']
: [];
return $decision + [
'effective_value' => $entitlementDecision['effective_value'] ?? null,
'source' => $decision['source'] ?? null,
'current_usage' => $entitlementDecision['current_usage'] ?? null,
'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null,
];
}
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->managed_environment_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(ManagedEnvironment $tenant, string $fingerprint): ?ReviewPack
{
return ReviewPack::query()
->forTenant((int) $tenant->getKey())
->ready()
->where('fingerprint', $fingerprint)
->where('expires_at', '>', now())
->first();
}
public function findExistingPackForReview(EnvironmentReview $review, string $fingerprint): ?ReviewPack
{
return ReviewPack::query()
->where('environment_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(ManagedEnvironment $tenant): bool
{
return OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->active()
->exists();
}
public function checkActiveRunForReview(EnvironmentReview $review): bool
{
return OperationRun::query()
->where('managed_environment_id', (int) $review->managed_environment_id)
->where('type', OperationRunType::ReviewPackGenerate->value)
->whereJsonContains('context->environment_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(ManagedEnvironment $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 = [
'managed_environment_id' => (int) $snapshot->managed_environment_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(EnvironmentReview $review, array $options): string
{
$data = [
'environment_review_id' => (int) $review->getKey(),
'review_fingerprint' => (string) $review->fingerprint,
'review_status' => (string) $review->status,
'delivery_contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
'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(ManagedEnvironment $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(ManagedEnvironment $tenant, OperationRun $operationRun): ?ReviewPack
{
return ReviewPack::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('operation_run_id', (int) $operationRun->getKey())
->latest('id')
->first();
}
private function logReviewExport(EnvironmentReview $review, User $user, ReviewPack $reviewPack, string $mode): void
{
$tenant = $review->tenant;
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$this->auditLogger->log(
workspace: $tenant->workspace,
action: AuditActionId::EnvironmentReviewExported,
context: [
'metadata' => [
'review_id' => (int) $review->getKey(),
'review_pack_id' => (int) $reviewPack->getKey(),
'mode' => $mode,
'status' => (string) $reviewPack->status,
],
],
actor: $user,
resourceType: 'environment_review',
resourceId: (string) $review->getKey(),
targetLabel: sprintf('ManagedEnvironment review #%d', (int) $review->getKey()),
operationRunId: $reviewPack->operation_run_id,
tenant: $tenant,
);
}
}