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:
@@ -41,6 +41,8 @@ dotnet_diagnostic.CA2007.severity = none
|
||||
# CA1848 / CA1873: LoggerMessage-delegate perf rules — opt-in perf, not worth enforcing in V1.
|
||||
dotnet_diagnostic.CA1848.severity = none
|
||||
dotnet_diagnostic.CA1873.severity = none
|
||||
# CA1031: a model/test boundary intentionally catches broadly to report any failure as a result.
|
||||
dotnet_diagnostic.CA1031.severity = none
|
||||
|
||||
# EF Core migrations are tool-generated — don't style-police them.
|
||||
[**/Migrations/*.cs]
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"Audience": "teamup",
|
||||
"ExpiryMinutes": 480
|
||||
},
|
||||
"Encryption": {
|
||||
"MasterKey": "dev-only-teamup-master-secret-change-in-production"
|
||||
},
|
||||
"OpenTelemetry": {
|
||||
"OtlpEndpoint": ""
|
||||
},
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"Audience": "teamup",
|
||||
"ExpiryMinutes": 480
|
||||
},
|
||||
"Encryption": {
|
||||
"MasterKey": "dev-only-teamup-master-secret-change-in-production"
|
||||
},
|
||||
"OpenTelemetry": {
|
||||
"OtlpEndpoint": ""
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Integrations.Persistence;
|
||||
using TeamUp.Modules.Integrations.Security;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Ai;
|
||||
|
||||
/// <summary>Resolves a BYOK config and decrypts its key — server-side only.</summary>
|
||||
internal sealed class ApiConfigResolver(IntegrationsDbContext db, ISecretProtector protector) : IApiConfigResolver
|
||||
{
|
||||
public async Task<ResolvedApiConfig?> ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == apiConfigId, cancellationToken);
|
||||
return config is null
|
||||
? null
|
||||
: new ResolvedApiConfig(
|
||||
config.Id, config.Name, config.Provider, config.Model, config.Endpoint,
|
||||
protector.Unprotect(config.EncryptedKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Ai;
|
||||
|
||||
/// <summary>No-network adapter for the "stub"/"echo" provider — used by tests and dogfood without keys.</summary>
|
||||
internal sealed class StubModelClient : IModelClient
|
||||
{
|
||||
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ModelCompletion(
|
||||
Success: true,
|
||||
Text: $"[stub {request.Provider}/{request.Model}] {request.Prompt}",
|
||||
Error: null,
|
||||
LatencyMs: 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-compatible /v1/chat/completions adapter (OpenAI, Ollama, vLLM, and OpenAI-compatible
|
||||
/// gateways). Returns a failed completion rather than throwing, so the connection test can report it.
|
||||
/// </summary>
|
||||
internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient
|
||||
{
|
||||
public async Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var baseUrl = (request.Endpoint ?? "https://api.openai.com").TrimEnd('/');
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/chat/completions");
|
||||
if (!string.IsNullOrEmpty(request.ApiKey))
|
||||
{
|
||||
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.ApiKey);
|
||||
}
|
||||
|
||||
message.Content = JsonContent.Create(new
|
||||
{
|
||||
model = request.Model,
|
||||
max_tokens = request.MaxTokens,
|
||||
messages = new[] { new { role = "user", content = request.Prompt } },
|
||||
});
|
||||
|
||||
using var response = await http.SendAsync(message, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ModelCompletion(false, null, $"HTTP {(int)response.StatusCode}", stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var doc = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken);
|
||||
var text = doc.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
|
||||
return new ModelCompletion(true, text, null, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new ModelCompletion(false, null, ex.Message, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Routes a request to the adapter for its provider.</summary>
|
||||
internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient
|
||||
{
|
||||
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
|
||||
request.Provider.ToLowerInvariant() switch
|
||||
{
|
||||
"stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken),
|
||||
_ => openAi.CompleteAsync(request, cancellationToken),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A BYOK model configuration (a named provider+model with an encrypted key), owned at the org
|
||||
/// scope. Owner-only to create/test/delete; the key is encrypted at rest and never returned to a
|
||||
/// client after save — team owners assign a config by id without ever seeing the key.
|
||||
/// </summary>
|
||||
internal sealed class ApiConfig : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public string Name { get; private set; } = null!;
|
||||
public string Provider { get; private set; } = null!;
|
||||
public string Model { get; private set; } = null!;
|
||||
public string? Endpoint { get; private set; }
|
||||
public string EncryptedKey { get; private set; } = null!;
|
||||
public Guid CreatedByMemberId { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private ApiConfig()
|
||||
{
|
||||
}
|
||||
|
||||
public ApiConfig(
|
||||
Guid organizationId,
|
||||
string name,
|
||||
string provider,
|
||||
string model,
|
||||
string? endpoint,
|
||||
string encryptedKey,
|
||||
Guid createdByMemberId,
|
||||
DateTimeOffset createdAtUtc)
|
||||
{
|
||||
OrganizationId = organizationId;
|
||||
Name = name;
|
||||
Provider = provider;
|
||||
Model = model;
|
||||
Endpoint = endpoint;
|
||||
EncryptedKey = encryptedKey;
|
||||
CreatedByMemberId = createdByMemberId;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TeamUp.Modules.Integrations.Endpoints;
|
||||
|
||||
internal sealed record CreateApiConfigRequest(
|
||||
Guid OrganizationId,
|
||||
string Name,
|
||||
string Provider,
|
||||
string Model,
|
||||
string? Endpoint,
|
||||
string ApiKey);
|
||||
|
||||
/// <summary>Public view of a config — never includes the key.</summary>
|
||||
internal sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
|
||||
|
||||
internal sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using TeamUp.Modules.Integrations.Ai;
|
||||
using TeamUp.Modules.Integrations.Endpoints;
|
||||
using TeamUp.Modules.Integrations.Git;
|
||||
using TeamUp.Modules.Integrations.Persistence;
|
||||
using TeamUp.Modules.Integrations.Security;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Git;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
namespace TeamUp.Modules.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// BYOK API configs, the Git connection, the encrypted-credential store. In M2 it provides the
|
||||
/// <see cref="IGitProvider"/> (filesystem for dogfood, Gitea over REST). BYOK lands in M3.
|
||||
/// BYOK API configs (encrypted, owner-only), the model-client adapters, and the Git connection.
|
||||
/// Encryption keys are owner-only and server-side; the decrypted key never leaves the server.
|
||||
/// </summary>
|
||||
public sealed class IntegrationsModule : IModule
|
||||
{
|
||||
@@ -19,10 +25,26 @@ public sealed class IntegrationsModule : IModule
|
||||
|
||||
public void Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
|
||||
var options = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? new GitSourceOptions();
|
||||
var connectionString = configuration.GetConnectionString("Postgres")
|
||||
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
|
||||
|
||||
if (string.Equals(options.Provider, "gitea", StringComparison.OrdinalIgnoreCase))
|
||||
// BYOK credential store + encryption.
|
||||
services.AddDbContext<IntegrationsDbContext>(options => options.UseNpgsql(connectionString));
|
||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<IntegrationsDbContext>());
|
||||
services.Configure<EncryptionOptions>(configuration.GetSection(EncryptionOptions.SectionName));
|
||||
services.AddSingleton<ISecretProtector, AesGcmSecretProtector>();
|
||||
services.AddScoped<IApiConfigResolver, ApiConfigResolver>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Model clients: a router over per-provider adapters.
|
||||
services.AddSingleton<StubModelClient>();
|
||||
services.AddHttpClient<OpenAiCompatibleModelClient>();
|
||||
services.AddScoped<IModelClient, ModelClientRouter>();
|
||||
|
||||
// Git source (M2) — filesystem for dogfood, Gitea over REST when configured.
|
||||
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
|
||||
var gitOptions = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? new GitSourceOptions();
|
||||
if (string.Equals(gitOptions.Provider, "gitea", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
services.AddHttpClient<IGitProvider, GiteaGitProvider>();
|
||||
}
|
||||
@@ -32,10 +54,5 @@ public sealed class IntegrationsModule : IModule
|
||||
}
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGroup($"/api/{Name}")
|
||||
.WithTags("Integrations")
|
||||
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
||||
}
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints) => IntegrationsEndpoints.Map(endpoints);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Integrations.Domain;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Persistence;
|
||||
|
||||
internal sealed class IntegrationsDbContext(DbContextOptions<IntegrationsDbContext> options)
|
||||
: DbContext(options), IModuleDbContext
|
||||
{
|
||||
public DbSet<ApiConfig> ApiConfigs => Set<ApiConfig>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("integrations");
|
||||
|
||||
modelBuilder.Entity<ApiConfig>(config =>
|
||||
{
|
||||
config.ToTable("api_configs");
|
||||
config.HasKey(c => c.Id);
|
||||
config.Property(c => c.Name).HasMaxLength(120).IsRequired();
|
||||
config.Property(c => c.Provider).HasMaxLength(60).IsRequired();
|
||||
config.Property(c => c.Model).HasMaxLength(120).IsRequired();
|
||||
config.Property(c => c.Endpoint).HasMaxLength(500);
|
||||
config.Property(c => c.EncryptedKey).IsRequired();
|
||||
config.HasIndex(c => c.OrganizationId);
|
||||
config.HasIndex(c => new { c.OrganizationId, c.Name }).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Persistence;
|
||||
|
||||
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
|
||||
internal sealed class IntegrationsDbContextFactory : IDesignTimeDbContextFactory<IntegrationsDbContext>
|
||||
{
|
||||
public IntegrationsDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
|
||||
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
|
||||
|
||||
var options = new DbContextOptionsBuilder<IntegrationsDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new IntegrationsDbContext(options);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeamUp.Modules.Integrations.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(IntegrationsDbContext))]
|
||||
[Migration("20260609194740_InitialIntegrations")]
|
||||
partial class InitialIntegrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("integrations")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Integrations.Domain.ApiConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EncryptedKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("OrganizationId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("api_configs", "integrations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialIntegrations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "integrations");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "api_configs",
|
||||
schema: "integrations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Provider = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||
Model = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Endpoint = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
EncryptedKey = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedByMemberId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_api_configs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_api_configs_OrganizationId",
|
||||
schema: "integrations",
|
||||
table: "api_configs",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_api_configs_OrganizationId_Name",
|
||||
schema: "integrations",
|
||||
table: "api_configs",
|
||||
columns: new[] { "OrganizationId", "Name" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "api_configs",
|
||||
schema: "integrations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeamUp.Modules.Integrations.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(IntegrationsDbContext))]
|
||||
partial class IntegrationsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("integrations")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Integrations.Domain.ApiConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EncryptedKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("OrganizationId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("api_configs", "integrations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Security;
|
||||
|
||||
internal sealed class EncryptionOptions
|
||||
{
|
||||
public const string SectionName = "Encryption";
|
||||
|
||||
/// <summary>Deployment master secret. A 32-byte AES key is derived from it (SHA-256).</summary>
|
||||
public string MasterKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal interface ISecretProtector
|
||||
{
|
||||
string Protect(string plaintext);
|
||||
|
||||
string Unprotect(string protectedValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM authenticated encryption with a key derived from the deployment master secret.
|
||||
/// Output blob = nonce(12) ‖ tag(16) ‖ ciphertext, base64-encoded.
|
||||
/// </summary>
|
||||
internal sealed class AesGcmSecretProtector : ISecretProtector
|
||||
{
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
private readonly byte[] _key;
|
||||
|
||||
public AesGcmSecretProtector(IOptions<EncryptionOptions> options)
|
||||
{
|
||||
var masterKey = options.Value.MasterKey;
|
||||
if (string.IsNullOrWhiteSpace(masterKey))
|
||||
{
|
||||
throw new InvalidOperationException("Missing 'Encryption:MasterKey'.");
|
||||
}
|
||||
|
||||
_key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey));
|
||||
}
|
||||
|
||||
public string Protect(string plaintext)
|
||||
{
|
||||
var plain = Encoding.UTF8.GetBytes(plaintext);
|
||||
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
||||
var cipher = new byte[plain.Length];
|
||||
var tag = new byte[TagSize];
|
||||
|
||||
using var aes = new AesGcm(_key, TagSize);
|
||||
aes.Encrypt(nonce, plain, cipher, tag);
|
||||
|
||||
var blob = new byte[NonceSize + TagSize + cipher.Length];
|
||||
Buffer.BlockCopy(nonce, 0, blob, 0, NonceSize);
|
||||
Buffer.BlockCopy(tag, 0, blob, NonceSize, TagSize);
|
||||
Buffer.BlockCopy(cipher, 0, blob, NonceSize + TagSize, cipher.Length);
|
||||
return Convert.ToBase64String(blob);
|
||||
}
|
||||
|
||||
public string Unprotect(string protectedValue)
|
||||
{
|
||||
var blob = Convert.FromBase64String(protectedValue);
|
||||
var nonce = blob.AsSpan(0, NonceSize);
|
||||
var tag = blob.AsSpan(NonceSize, TagSize);
|
||||
var cipher = blob.AsSpan(NonceSize + TagSize);
|
||||
var plain = new byte[cipher.Length];
|
||||
|
||||
using var aes = new AesGcm(_key, TagSize);
|
||||
aes.Decrypt(nonce, cipher, tag, plain);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
|
||||
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
|
||||
gains an (internal) DbContext and validators. It must never reference another module.
|
||||
NOTE: the AI model-client packages (Microsoft.Extensions.AI, ONNX) are deferred to M3-M4;
|
||||
this module exposes only seam interfaces in V1, no concrete model client. -->
|
||||
<!-- BYOK API configs, the Git connection, the encrypted-credential store (M3). References
|
||||
SharedKernel only. The Git provider uses framework HttpClient; the BYOK store uses EF Core.
|
||||
Model calls go through thin HTTP adapters (no Microsoft.Extensions.AI dependency yet). -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user