M3: BYOK — encrypted owner-only API configs + model adapters

SharedKernel: Autonomy dial enum; IModelClient (ModelRequest/ModelCompletion);
IApiConfigResolver (+ ApiConfigSummary/ResolvedApiConfig) — server-side, decrypted.

Integrations module:
- ApiConfig entity (org-scoped) + IntegrationsDbContext (schema "integrations") +
  InitialIntegrations migration; the key is AES-256-GCM encrypted at rest (key derived from
  Encryption:MasterKey) and never returned to a client.
- Model adapters: StubModelClient (no-network, provider "stub"/"echo"), an OpenAI-compatible
  HTTP adapter, and a ModelClientRouter; ApiConfigResolver decrypts server-side only.
- Endpoints: POST/GET/DELETE /api/integrations/api-configs and POST .../{id}/test. Create/
  test/delete require ManageApiKeys (owner); listing requires ConfigureAgents (assign-only,
  no key). Dev master key in appsettings; override Encryption__MasterKey in prod.

Verified: build green; ArchitectureTests 8/8 (Integrations references only SharedKernel);
IntegrationTests 26/26 incl. a BYOK flow — key never appears in any response, the connection
test succeeds (stub), and a Member is 403'd from create + list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 23:26:28 +03:30
parent de7501b8e7
commit 1559975518
20 changed files with 827 additions and 18 deletions
+125
View File
@@ -0,0 +1,125 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// M3 BYOK acceptance: an owner adds an API config (key encrypted, never returned by any endpoint),
/// a connection test succeeds, and a non-owner Member cannot create or list configs.
/// </summary>
public sealed class ByokTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private const string SecretKey = "sk-teamup-test-deadbeef-do-not-leak";
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record AuthResponse(string Token, Guid MemberId);
private sealed record InviteResponse(Guid InvitationId, string Token);
private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
private sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample);
[Fact]
public async Task Owner_adds_config_key_never_returned_test_succeeds_member_forbidden()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var anon = factory.CreateClient();
var owner = await Bootstrap(anon);
using var ownerClient = Authed(factory, owner.Token);
// Owner creates a config (stub provider, no network). The key must NOT appear in the response.
var createResponse = await ownerClient.PostAsJsonAsync("/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Stub-Pro",
provider = "stub",
model = "test-model",
apiKey = SecretKey,
});
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
Assert.DoesNotContain(SecretKey, await createResponse.Content.ReadAsStringAsync());
var config = await createResponse.Content.ReadFromJsonAsync<ApiConfigDto>();
Assert.NotNull(config);
Assert.Equal("stub", config!.Provider);
// Listing returns the config but never the key.
var listResponse = await ownerClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listBody = await listResponse.Content.ReadAsStringAsync();
Assert.DoesNotContain(SecretKey, listBody);
Assert.Contains(config.Id.ToString(), listBody);
// The connection test succeeds (stub uses the decrypted key server-side; never echoes it).
var test = await ownerClient.PostAsync($"/api/integrations/api-configs/{config.Id}/test", content: null);
Assert.Equal(HttpStatusCode.OK, test.StatusCode);
var testBody = await test.Content.ReadAsStringAsync();
Assert.DoesNotContain(SecretKey, testBody);
var result = await test.Content.ReadFromJsonAsync<TestResultDto>();
Assert.True(result!.Success);
// A Member cannot manage or even list BYOK configs.
var member = await InviteMember(ownerClient, anon, owner.OrganizationId);
using var memberClient = Authed(factory, member.Token);
var memberCreate = await memberClient.PostAsJsonAsync("/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Nope",
provider = "stub",
model = "x",
apiKey = "sk-nope",
});
Assert.Equal(HttpStatusCode.Forbidden, memberCreate.StatusCode);
var memberList = await memberClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}");
Assert.Equal(HttpStatusCode.Forbidden, memberList.StatusCode);
}
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
{
var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
var owner = await response.Content.ReadFromJsonAsync<BootstrapResponse>();
Assert.NotNull(owner);
return owner!;
}
private static async Task<AuthResponse> InviteMember(HttpClient ownerClient, HttpClient anon, Guid organizationId)
{
var invite = await ownerClient.PostAsJsonAsync("/api/identity/invitations", new
{
email = "dev@alia.test",
scopeType = "Organization",
scopeId = organizationId,
role = "Member",
organizationId,
});
var inviteResponse = await invite.Content.ReadFromJsonAsync<InviteResponse>();
var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new
{
token = inviteResponse!.Token,
displayName = "Dev",
password = "Passw0rd!",
});
var member = await accept.Content.ReadFromJsonAsync<AuthResponse>();
Assert.NotNull(member);
return member!;
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
}