From 155997551879c09de54dcd31618287596e41c4b7 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 9 Jun 2026 23:26:28 +0330 Subject: [PATCH] =?UTF-8?q?M3:=20BYOK=20=E2=80=94=20encrypted=20owner-only?= =?UTF-8?q?=20API=20configs=20+=20model=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .editorconfig | 2 + src/Hosts/TeamUp.Web/appsettings.json | 3 + src/Hosts/TeamUp.Worker/appsettings.json | 3 + .../Ai/ApiConfigResolver.cs | 20 +++ .../Ai/ModelClients.cs | 72 ++++++++++ .../Domain/ApiConfig.cs | 44 ++++++ .../Endpoints/IntegrationsDtos.cs | 14 ++ .../Endpoints/IntegrationsEndpoints.cs | 120 +++++++++++++++++ .../IntegrationsModule.cs | 43 ++++-- .../Persistence/IntegrationsDbContext.cs | 29 ++++ .../IntegrationsDbContextFactory.cs | 21 +++ ...0609194740_InitialIntegrations.Designer.cs | 79 +++++++++++ .../20260609194740_InitialIntegrations.cs | 59 +++++++++ .../IntegrationsDbContextModelSnapshot.cs | 76 +++++++++++ .../Security/SecretProtector.cs | 72 ++++++++++ .../TeamUp.Modules.Integrations.csproj | 14 +- .../TeamUp.SharedKernel/Access/Autonomy.cs | 12 ++ .../Ai/IApiConfigResolver.cs | 16 +++ .../TeamUp.SharedKernel/Ai/IModelClient.cs | 21 +++ tests/TeamUp.IntegrationTests/ByokTests.cs | 125 ++++++++++++++++++ 20 files changed, 827 insertions(+), 18 deletions(-) create mode 100644 src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs create mode 100644 tests/TeamUp.IntegrationTests/ByokTests.cs diff --git a/.editorconfig b/.editorconfig index 0166bd8..e28d1f2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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] diff --git a/src/Hosts/TeamUp.Web/appsettings.json b/src/Hosts/TeamUp.Web/appsettings.json index f23be41..88af75b 100644 --- a/src/Hosts/TeamUp.Web/appsettings.json +++ b/src/Hosts/TeamUp.Web/appsettings.json @@ -11,6 +11,9 @@ "Audience": "teamup", "ExpiryMinutes": 480 }, + "Encryption": { + "MasterKey": "dev-only-teamup-master-secret-change-in-production" + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/src/Hosts/TeamUp.Worker/appsettings.json b/src/Hosts/TeamUp.Worker/appsettings.json index c17bfab..79ea812 100644 --- a/src/Hosts/TeamUp.Worker/appsettings.json +++ b/src/Hosts/TeamUp.Worker/appsettings.json @@ -11,6 +11,9 @@ "Audience": "teamup", "ExpiryMinutes": 480 }, + "Encryption": { + "MasterKey": "dev-only-teamup-master-secret-change-in-production" + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs b/src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs new file mode 100644 index 0000000..96effef --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs @@ -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; + +/// Resolves a BYOK config and decrypts its key — server-side only. +internal sealed class ApiConfigResolver(IntegrationsDbContext db, ISecretProtector protector) : IApiConfigResolver +{ + public async Task 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)); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs b/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs new file mode 100644 index 0000000..a1eaea8 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs @@ -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; + +/// No-network adapter for the "stub"/"echo" provider — used by tests and dogfood without keys. +internal sealed class StubModelClient : IModelClient +{ + public Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(new ModelCompletion( + Success: true, + Text: $"[stub {request.Provider}/{request.Model}] {request.Prompt}", + Error: null, + LatencyMs: 0)); +} + +/// +/// 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. +/// +internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient +{ + public async Task 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(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); + } + } +} + +/// Routes a request to the adapter for its provider. +internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient +{ + public Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) => + request.Provider.ToLowerInvariant() switch + { + "stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken), + _ => openAi.CompleteAsync(request, cancellationToken), + }; +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs b/src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs new file mode 100644 index 0000000..de08be2 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs @@ -0,0 +1,44 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Integrations.Domain; + +/// +/// 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. +/// +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; + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs new file mode 100644 index 0000000..a9acd69 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs @@ -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); + +/// Public view of a config — never includes the key. +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); diff --git a/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs new file mode 100644 index 0000000..6a5f024 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs @@ -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 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 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 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 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); +} diff --git a/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs b/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs index ea66bc0..fca029b 100644 --- a/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs +++ b/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs @@ -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; /// -/// BYOK API configs, the Git connection, the encrypted-credential store. In M2 it provides the -/// (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. /// public sealed class IntegrationsModule : IModule { @@ -19,10 +25,26 @@ public sealed class IntegrationsModule : IModule public void Register(IServiceCollection services, IConfiguration configuration) { - services.Configure(configuration.GetSection(GitSourceOptions.SectionName)); - var options = configuration.GetSection(GitSourceOptions.SectionName).Get() ?? 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(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + services.Configure(configuration.GetSection(EncryptionOptions.SectionName)); + services.AddSingleton(); + services.AddScoped(); + services.TryAddSingleton(TimeProvider.System); + + // Model clients: a router over per-provider adapters. + services.AddSingleton(); + services.AddHttpClient(); + services.AddScoped(); + + // Git source (M2) — filesystem for dogfood, Gitea over REST when configured. + services.Configure(configuration.GetSection(GitSourceOptions.SectionName)); + var gitOptions = configuration.GetSection(GitSourceOptions.SectionName).Get() ?? new GitSourceOptions(); + if (string.Equals(gitOptions.Provider, "gitea", StringComparison.OrdinalIgnoreCase)) { services.AddHttpClient(); } @@ -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); } diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs new file mode 100644 index 0000000..4edb33c --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs @@ -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 options) + : DbContext(options), IModuleDbContext +{ + public DbSet ApiConfigs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("integrations"); + + modelBuilder.Entity(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(); + }); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs new file mode 100644 index 0000000..585f1e1 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TeamUp.Modules.Integrations.Persistence; + +/// Design-time factory so `dotnet ef` can build the internal context without a host. +internal sealed class IntegrationsDbContextFactory : IDesignTimeDbContextFactory +{ + 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() + .UseNpgsql(connectionString) + .Options; + + return new IntegrationsDbContext(options); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs new file mode 100644 index 0000000..905b427 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs @@ -0,0 +1,79 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("EncryptedKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs new file mode 100644 index 0000000..18873f4 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.Integrations.Persistence.Migrations +{ + /// + public partial class InitialIntegrations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "integrations"); + + migrationBuilder.CreateTable( + name: "api_configs", + schema: "integrations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Provider = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + Model = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Endpoint = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + EncryptedKey = table.Column(type: "text", nullable: false), + CreatedByMemberId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "api_configs", + schema: "integrations"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs new file mode 100644 index 0000000..744df39 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs @@ -0,0 +1,76 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("EncryptedKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs b/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs new file mode 100644 index 0000000..3732a9d --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs @@ -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"; + + /// Deployment master secret. A 32-byte AES key is derived from it (SHA-256). + public string MasterKey { get; set; } = string.Empty; +} + +internal interface ISecretProtector +{ + string Protect(string plaintext); + + string Unprotect(string protectedValue); +} + +/// +/// AES-256-GCM authenticated encryption with a key derived from the deployment master secret. +/// Output blob = nonce(12) ‖ tag(16) ‖ ciphertext, base64-encoded. +/// +internal sealed class AesGcmSecretProtector : ISecretProtector +{ + private const int NonceSize = 12; + private const int TagSize = 16; + private readonly byte[] _key; + + public AesGcmSecretProtector(IOptions 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); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj b/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj index 876bf04..ef26d73 100644 --- a/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj +++ b/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj @@ -1,12 +1,16 @@ - + + + + + + + diff --git a/src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs b/src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs new file mode 100644 index 0000000..66121b0 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs @@ -0,0 +1,12 @@ +namespace TeamUp.SharedKernel.Access; + +/// +/// 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. +/// +public enum Autonomy +{ + DraftOnly, + Gated, + Autonomous, +} diff --git a/src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs b/src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs new file mode 100644 index 0000000..ef1cd86 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs @@ -0,0 +1,16 @@ +namespace TeamUp.SharedKernel.Ai; + +/// Non-sensitive BYOK config info (no key) — safe to list/return to clients. +public sealed record ApiConfigSummary(Guid Id, string Name, string Provider, string Model); + +/// A resolved config including the decrypted key. Server-side only — never serialized to a client. +public sealed record ResolvedApiConfig(Guid Id, string Name, string Provider, string Model, string? Endpoint, string ApiKey); + +/// +/// 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. +/// +public interface IApiConfigResolver +{ + Task ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default); +} diff --git a/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs b/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs new file mode 100644 index 0000000..964c965 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs @@ -0,0 +1,21 @@ +namespace TeamUp.SharedKernel.Ai; + +/// One model invocation. The key is passed explicitly (BYOK, server-side only). +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); + +/// +/// 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. +/// +public interface IModelClient +{ + Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default); +} diff --git a/tests/TeamUp.IntegrationTests/ByokTests.cs b/tests/TeamUp.IntegrationTests/ByokTests.cs new file mode 100644 index 0000000..1315975 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/ByokTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// 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. +/// +public sealed class ByokTests(PostgresFixture postgres) : IClassFixture +{ + 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(); + 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(); + 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 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(); + Assert.NotNull(owner); + return owner!; + } + + private static async Task 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(); + var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new + { + token = inviteResponse!.Token, + displayName = "Dev", + password = "Passw0rd!", + }); + var member = await accept.Content.ReadFromJsonAsync(); + 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; + } +}