feat(ai): enforce local-first routing
Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
This commit is contained in:
@@ -9,6 +9,7 @@ public sealed record AiPrivacyDecision(bool ExternalProcessingAllowed, string Pr
|
||||
public sealed class AiPrivacyPolicy(IConfiguration configuration, IServiceScopeFactory scopes)
|
||||
{
|
||||
public const string ExternalAllowedHeader = "X-Ai-External-Allowed";
|
||||
public const string TaskTypeHeader = "X-Ai-Task-Type";
|
||||
|
||||
public string ExternalProvider
|
||||
{
|
||||
@@ -19,8 +20,18 @@ public sealed class AiPrivacyPolicy(IConfiguration configuration, IServiceScopeF
|
||||
}
|
||||
}
|
||||
|
||||
public string RoutingMode
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = (configuration["Ai:RoutingMode"] ?? "local_first").Trim().ToLowerInvariant();
|
||||
return mode is "local_only" or "local_first" or "external_only" ? mode : "local_only";
|
||||
}
|
||||
}
|
||||
|
||||
public bool ExternalProcessingAvailable =>
|
||||
configuration.GetValue("Ai:ExternalProcessingEnabled", false)
|
||||
&& RoutingMode != "local_only"
|
||||
&& ExternalProvider is "gemini" or "groq";
|
||||
|
||||
public async Task<AiPrivacyDecision> EvaluateAsync(string? userId, CancellationToken cancellationToken = default)
|
||||
@@ -44,7 +55,8 @@ public sealed class AiPrivacyPolicy(IConfiguration configuration, IServiceScopeF
|
||||
|
||||
public sealed class AiPrivacyHeaderHandler(
|
||||
IHttpContextAccessor httpContext,
|
||||
AiPrivacyPolicy privacyPolicy) : DelegatingHandler
|
||||
AiPrivacyPolicy privacyPolicy,
|
||||
AiOperationExecutionScope executionScope) : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
@@ -52,10 +64,18 @@ public sealed class AiPrivacyHeaderHandler(
|
||||
{
|
||||
if (request.RequestUri?.AbsolutePath.StartsWith("/cv/", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var userId = httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? httpContext.HttpContext?.User.FindFirstValue("sub");
|
||||
var decision = await privacyPolicy.EvaluateAsync(userId, cancellationToken);
|
||||
if (decision.ExternalProcessingAllowed)
|
||||
var operationContext = executionScope.Current;
|
||||
var externalAllowed = operationContext?.EffectivePrivacyPolicy == "external_allowed";
|
||||
if (operationContext is not null)
|
||||
request.Headers.TryAddWithoutValidation(AiPrivacyPolicy.TaskTypeHeader, operationContext.Lease.TaskType);
|
||||
else
|
||||
{
|
||||
var userId = httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? httpContext.HttpContext?.User.FindFirstValue("sub");
|
||||
externalAllowed = (await privacyPolicy.EvaluateAsync(userId, cancellationToken)).ExternalProcessingAllowed;
|
||||
}
|
||||
|
||||
if (externalAllowed)
|
||||
request.Headers.TryAddWithoutValidation(AiPrivacyPolicy.ExternalAllowedHeader, "true");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user