TenantAtlas/apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
ahmido ff3392892b
Some checks failed
Main Confidence / confidence (push) Failing after 56s
Merge 248-private-ai-policy-foundation into dev (#288)
Automated PR: merge branch 248-private-ai-policy-foundation into dev (created by Copilot)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #288
2026-04-27 21:18:37 +00: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: [],
);
}
}