Files
Teamup/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs
T
soroush.asadi 1559975518 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>
2026-06-09 23:26:28 +03:30

121 lines
4.7 KiB
C#

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);
}