TenantAtlas/apps/platform/app/Services/ReviewPackService.php
ahmido bd6f59bb7c feat: add governance artifact lifecycle retention contracts (#477)
Automated PR provided by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #477
2026-06-24 08:29:30 +00:00

601 lines
22 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\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
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\Auth\Capabilities;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\ReviewPackStatus;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Throwable;
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),
);
}
/**
* @return array{path: string, bytes: string, size: int, sha256: string}|null
*/
public function resolveDownloadableArtifact(ReviewPack $pack): ?array
{
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
if ($pack->file_disk !== 'exports' || ! filled($pack->file_path)) {
return null;
}
if ((int) $pack->file_size <= 0 || ! filled($pack->sha256)) {
return null;
}
$path = (string) $pack->file_path;
$disk = Storage::disk('exports');
try {
if (! $disk->exists($path)) {
return null;
}
$fileSize = (int) $disk->size($path);
$fileBytes = $disk->get($path);
} catch (Throwable) {
return null;
}
if (! is_string($fileBytes)) {
return null;
}
if ($fileSize <= 0 || $fileSize !== (int) $pack->file_size) {
return null;
}
if (! hash_equals((string) $pack->sha256, hash('sha256', $fileBytes))) {
return null;
}
return [
'path' => $path,
'bytes' => $fileBytes,
'size' => $fileSize,
'sha256' => (string) $pack->sha256,
];
}
public function hasDownloadableArtifact(ReviewPack $pack): bool
{
return $this->resolveDownloadableArtifact($pack) !== null;
}
public function expire(ReviewPack $reviewPack, User $actor, string $sourceSurface = 'review_pack'): ReviewPack
{
$reviewPack->loadMissing(['tenant.workspace']);
$tenant = $reviewPack->tenant;
if (! $tenant instanceof ManagedEnvironment || ! $tenant->workspace) {
throw new AuthorizationException('Review pack scope is unavailable.');
}
if (! $actor->canAccessTenant($tenant)) {
throw new AuthorizationException('Review pack not found.');
}
Gate::forUser($actor)->authorize(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$beforeStatus = (string) $reviewPack->status;
if ($beforeStatus !== ReviewPackStatus::Ready->value) {
throw new \InvalidArgumentException('Only ready review packs can be expired.');
}
$fileDeleted = false;
$fileWasPresent = false;
$fileDisk = is_string($reviewPack->file_disk) ? $reviewPack->file_disk : null;
$filePath = is_string($reviewPack->file_path) ? $reviewPack->file_path : null;
if ($fileDisk === 'exports' && filled($filePath)) {
$disk = Storage::disk('exports');
if ($disk->exists((string) $filePath)) {
$fileWasPresent = true;
if (! $disk->delete((string) $filePath) || $disk->exists((string) $filePath)) {
throw new \RuntimeException('Unable to delete the review pack file before expiring it.');
}
$fileDeleted = true;
}
}
$reviewPack->forceFill([
'status' => ReviewPackStatus::Expired->value,
])->save();
$this->auditLogger->log(
workspace: $tenant->workspace,
action: AuditActionId::ReviewPackExpired,
context: [
'metadata' => [
'review_pack_id' => (int) $reviewPack->getKey(),
'environment_review_id' => $reviewPack->environment_review_id !== null
? (int) $reviewPack->environment_review_id
: null,
'artifact_family' => 'review_pack',
'before_status' => $beforeStatus,
'after_status' => ReviewPackStatus::Expired->value,
'file_disk' => $fileDisk,
'file_path_present' => filled($filePath),
'file_present_before' => $fileWasPresent,
'file_deleted' => $fileDeleted,
'source_surface' => $sourceSurface,
],
],
actor: $actor,
resourceType: 'review_pack',
resourceId: (string) $reviewPack->getKey(),
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
tenant: $tenant,
operationRunId: $reviewPack->operation_run_id,
);
return $reviewPack->refresh();
}
/**
* 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);
}
public 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,
);
}
}