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:
cesnimda
2026-08-09 12:30:11 +02:00
parent c3f4a57195
commit 5eb9b3cb96
29 changed files with 967 additions and 145 deletions
+45 -2
View File
@@ -46,6 +46,20 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("local", decision.Provider);
}
[Theory]
[InlineData("local_only")]
[InlineData("unexpected")]
public async Task Local_only_or_invalid_admin_mode_disables_external_processing(string routingMode)
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: true, routingMode: routingMode);
await fixture.CreateUserAsync(externalAllowed: true, pro: true);
var decision = await fixture.Policy.EvaluateAsync("user-1");
Assert.False(decision.ExternalProcessingAllowed);
Assert.Equal("local", decision.Provider);
}
[Fact]
public async Task Cv_request_carries_external_permission_only_after_the_policy_allows_it()
{
@@ -57,7 +71,10 @@ public sealed class AiPrivacyPolicyTests
new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")),
};
var capture = new CaptureHandler();
var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor { HttpContext = context }, fixture.Policy)
var handler = new AiPrivacyHeaderHandler(
new HttpContextAccessor { HttpContext = context },
fixture.Policy,
new AiOperationExecutionScope())
{
InnerHandler = capture,
};
@@ -68,15 +85,40 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("true", capture.ExternalAllowed);
}
[Fact]
public async Task Background_operation_carries_its_rechecked_policy_and_task_without_http_user_context()
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: true);
var executionScope = new AiOperationExecutionScope();
var capture = new CaptureHandler();
var handler = new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, executionScope)
{
InnerHandler = capture,
};
var lease = new UserOperationLease(Guid.NewGuid(), "user-1", "lease", "strategy.snapshot",
"external_allowed", "job", "42", 1, DateTime.UtcNow.AddMinutes(5));
using var routing = executionScope.Use(new AiOperationExecutionContext(lease, "external_allowed"));
using var client = new HttpClient(handler);
await client.PostAsync("http://ai-service/cv/rewrite", new StringContent("{}"));
Assert.Equal("true", capture.ExternalAllowed);
Assert.Equal("strategy.snapshot", capture.TaskType);
}
private sealed class CaptureHandler : HttpMessageHandler
{
public string? ExternalAllowed { get; private set; }
public string? TaskType { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
ExternalAllowed = request.Headers.TryGetValues(AiPrivacyPolicy.ExternalAllowedHeader, out var values)
? values.Single()
: null;
TaskType = request.Headers.TryGetValues(AiPrivacyPolicy.TaskTypeHeader, out var taskValues)
? taskValues.Single()
: null;
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
}
@@ -94,7 +136,7 @@ public sealed class AiPrivacyPolicyTests
Policy = policy;
}
public static async Task<Fixture> CreateAsync(bool adminEnabled)
public static async Task<Fixture> CreateAsync(bool adminEnabled, string routingMode = "local_first")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
@@ -102,6 +144,7 @@ public sealed class AiPrivacyPolicyTests
{
["Ai:ExternalProcessingEnabled"] = adminEnabled.ToString(),
["Ai:ExternalProvider"] = "gemini",
["Ai:RoutingMode"] = routingMode,
}).Build();
var services = new ServiceCollection();
services.AddLogging();