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:
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Integrations.Domain;
|
||||
using TeamUp.Modules.Integrations.Persistence;
|
||||
using TeamUp.Modules.Integrations.Security;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Endpoints;
|
||||
|
||||
internal static class IntegrationsEndpoints
|
||||
{
|
||||
public static void Map(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/integrations").WithTags("Integrations");
|
||||
|
||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("integrations")));
|
||||
group.MapPost("/api-configs", CreateApiConfig).RequireAuthorization();
|
||||
group.MapGet("/api-configs", ListApiConfigs).RequireAuthorization();
|
||||
group.MapPost("/api-configs/{id:guid}/test", TestApiConfig).RequireAuthorization();
|
||||
group.MapDelete("/api-configs/{id:guid}", DeleteApiConfig).RequireAuthorization();
|
||||
}
|
||||
|
||||
// Owner-only. Encrypts the key; the response never includes it.
|
||||
private static async Task<IResult> CreateApiConfig(
|
||||
CreateApiConfigRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IntegrationsDbContext db, ISecretProtector protector, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Provider)
|
||||
|| string.IsNullOrWhiteSpace(request.Model) || string.IsNullOrWhiteSpace(request.ApiKey))
|
||||
{
|
||||
return Results.BadRequest("Name, provider, model and apiKey are required.");
|
||||
}
|
||||
|
||||
var config = new ApiConfig(
|
||||
request.OrganizationId, request.Name.Trim(), request.Provider.Trim(), request.Model.Trim(),
|
||||
request.Endpoint, protector.Protect(request.ApiKey), user.MemberId, clock.GetUtcNow());
|
||||
|
||||
db.ApiConfigs.Add(config);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(ToDto(config));
|
||||
}
|
||||
|
||||
// Team owners may list (to assign) — without ever seeing the key.
|
||||
private static async Task<IResult> ListApiConfigs(
|
||||
Guid organizationId, IPermissionService permissions, IntegrationsDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var configs = await db.ApiConfigs
|
||||
.Where(c => c.OrganizationId == organizationId)
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new ApiConfigDto(c.Id, c.Name, c.Provider, c.Model, c.Endpoint))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(configs);
|
||||
}
|
||||
|
||||
// Owner-only. Resolves + decrypts server-side, makes a tiny model call, returns the outcome.
|
||||
private static async Task<IResult> TestApiConfig(
|
||||
Guid id, IPermissionService permissions, IntegrationsDbContext db,
|
||||
IApiConfigResolver resolver, IModelClient model, CancellationToken ct)
|
||||
{
|
||||
var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
if (config is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(config.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var resolved = await resolver.ResolveAsync(id, ct);
|
||||
if (resolved is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var completion = await model.CompleteAsync(
|
||||
new ModelRequest(resolved.Provider, resolved.Model, resolved.ApiKey, resolved.Endpoint, "ping", MaxTokens: 16), ct);
|
||||
|
||||
var sample = completion.Text is { Length: > 0 } text ? text[..Math.Min(text.Length, 80)] : null;
|
||||
return Results.Ok(new TestResultDto(completion.Success, completion.Error, completion.LatencyMs, sample));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteApiConfig(
|
||||
Guid id, IPermissionService permissions, IntegrationsDbContext db, CancellationToken ct)
|
||||
{
|
||||
var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
if (config is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(config.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
db.ApiConfigs.Remove(config);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static ApiConfigDto ToDto(ApiConfig config) =>
|
||||
new(config.Id, config.Name, config.Provider, config.Model, config.Endpoint);
|
||||
}
|
||||
Reference in New Issue
Block a user