TenantAtlas/apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Ahmed Darrazi 6383f205a1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
chore: commit all changes (automated) 2026-04-27T21:17:40Z
2026-04-27 23:17:40 +02:00

181 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use App\Support\OperationalControls\OperationalControlEvaluator;
final class GovernedAiExecutionBoundary
{
public function __construct(
private readonly AiUseCaseCatalog $useCaseCatalog,
private readonly SettingsResolver $settingsResolver,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
{
$decision = $this->decisionFor($request);
$metadata = $this->auditMetadataFactory->make($request, $decision);
$decision = new AiExecutionDecision(
outcome: $decision->outcome,
reasonCode: $decision->reasonCode,
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
useCaseKey: $decision->useCaseKey,
requestedProviderClass: $decision->requestedProviderClass,
dataClassifications: $decision->dataClassifications,
sourceFamily: $decision->sourceFamily,
auditAction: $decision->auditAction,
auditMetadata: $metadata,
);
if ($request->workspace !== null) {
$definition = $this->useCaseCatalog->find($request->useCaseKey);
$this->workspaceAuditLogger->log(
workspace: $request->workspace,
action: $decision->auditAction,
context: ['metadata' => $decision->auditMetadata],
actor: $request->actor,
status: $decision->isAllowed() ? 'success' : 'blocked',
resourceType: 'ai_use_case',
resourceId: $request->useCaseKey,
targetLabel: $definition['label'] ?? $request->useCaseKey,
summary: 'AI execution decision evaluated',
tenant: $request->tenant,
);
}
return $decision;
}
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
{
if ($request->workspace === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
if ($controlDecision->isPaused()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
matchedOperationalControlScope: $controlDecision->matchedScopeType,
);
}
$policyMode = $this->resolvedPolicyMode($request);
if ($policyMode === AiPolicyMode::Disabled->value) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
workspaceAiPolicyMode: $policyMode,
);
}
$definition = $this->useCaseCatalog->find($request->useCaseKey);
if ($definition === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
workspaceAiPolicyMode: $policyMode,
);
}
if ($definition['source_family'] !== $request->sourceFamily) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
workspaceAiPolicyMode: $policyMode,
);
}
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
foreach ($request->dataClassifications as $classification) {
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
}
return new AiExecutionDecision(
outcome: 'allowed',
reasonCode: AiDecisionReasonCode::Allowed,
workspaceAiPolicyMode: $policyMode,
matchedOperationalControlScope: null,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
private function resolvedPolicyMode(AiExecutionRequest $request): string
{
if ($request->workspace === null) {
return AiPolicyMode::Disabled->value;
}
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
return is_string($resolved) && $resolved !== ''
? $resolved
: AiPolicyMode::Disabled->value;
}
private function blockedDecision(
AiExecutionRequest $request,
AiDecisionReasonCode $reasonCode,
string $workspaceAiPolicyMode,
?string $matchedOperationalControlScope = null,
): AiExecutionDecision {
return new AiExecutionDecision(
outcome: 'blocked',
reasonCode: $reasonCode,
workspaceAiPolicyMode: $workspaceAiPolicyMode,
matchedOperationalControlScope: $matchedOperationalControlScope,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
}