5eb9b3cb96
Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
85 lines
3.4 KiB
C#
85 lines
3.4 KiB
C#
using System.Security.Claims;
|
|
using JobTrackerApi.Models;
|
|
using Microsoft.AspNetCore.Identity;
|
|
|
|
namespace JobTrackerApi.Services;
|
|
|
|
public sealed record AiPrivacyDecision(bool ExternalProcessingAllowed, string Provider);
|
|
|
|
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
|
|
{
|
|
get
|
|
{
|
|
var provider = (configuration["Ai:ExternalProvider"] ?? "ollama").Trim().ToLowerInvariant();
|
|
return provider is "gemini" or "groq" ? provider : "ollama";
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (!ExternalProcessingAvailable || string.IsNullOrWhiteSpace(userId))
|
|
return new AiPrivacyDecision(false, "local");
|
|
|
|
await using var scope = scopes.CreateAsyncScope();
|
|
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var user = await users.FindByIdAsync(userId);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (user is null || !user.AiEnabled || !user.ExternalAiProcessingAllowed)
|
|
return new AiPrivacyDecision(false, "local");
|
|
|
|
var isPro = AccountPlans.ForRoles(await users.GetRolesAsync(user)).Ai;
|
|
return isPro
|
|
? new AiPrivacyDecision(true, ExternalProvider)
|
|
: new AiPrivacyDecision(false, "local");
|
|
}
|
|
}
|
|
|
|
public sealed class AiPrivacyHeaderHandler(
|
|
IHttpContextAccessor httpContext,
|
|
AiPrivacyPolicy privacyPolicy,
|
|
AiOperationExecutionScope executionScope) : DelegatingHandler
|
|
{
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request.RequestUri?.AbsolutePath.StartsWith("/cv/", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
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");
|
|
}
|
|
|
|
return await base.SendAsync(request, cancellationToken);
|
|
}
|
|
}
|