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
@@ -0,0 +1,12 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// The per-seat autonomy dial, set by the team owner. The action gate (M5) compares it to an
/// action's risk to decide execute-vs-hold. Stored on the Agent (M3); evaluated in Governance.
/// </summary>
public enum Autonomy
{
DraftOnly,
Gated,
Autonomous,
}
@@ -0,0 +1,16 @@
namespace TeamUp.SharedKernel.Ai;
/// <summary>Non-sensitive BYOK config info (no key) — safe to list/return to clients.</summary>
public sealed record ApiConfigSummary(Guid Id, string Name, string Provider, string Model);
/// <summary>A resolved config including the decrypted key. Server-side only — never serialized to a client.</summary>
public sealed record ResolvedApiConfig(Guid Id, string Name, string Provider, string Model, string? Endpoint, string ApiKey);
/// <summary>
/// Resolves a BYOK config (decrypting the key) for server-side use — the M3 connection test and the
/// M4 assembler. Implemented by Integrations; the decrypted key never leaves the server.
/// </summary>
public interface IApiConfigResolver
{
Task<ResolvedApiConfig?> ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
namespace TeamUp.SharedKernel.Ai;
/// <summary>One model invocation. The key is passed explicitly (BYOK, server-side only).</summary>
public sealed record ModelRequest(
string Provider,
string Model,
string ApiKey,
string? Endpoint,
string Prompt,
int MaxTokens = 256);
public sealed record ModelCompletion(bool Success, string? Text, string? Error, long LatencyMs);
/// <summary>
/// Provider-agnostic model client. Implemented in Integrations (a router over per-provider HTTP
/// adapters). Used by the M3 BYOK test call and the M4 assembler. BYOK — never resells tokens.
/// </summary>
public interface IModelClient
{
Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default);
}