feat(api): connections CRUD with encrypted storage + secret masking

This commit is contained in:
Claude
2026-08-28 12:13:37 +00:00
parent 37c35581ec
commit 89b721fdd0
7 changed files with 224 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
namespace VolcanicCore.Api.Auth;
public interface IConnectionSecretPolicy
{
IReadOnlySet<string> SecretFieldsFor(string providerId);
}
public sealed class HardcodedSecretPolicy : IConnectionSecretPolicy
{
private static readonly HashSet<string> Fields =
new(StringComparer.OrdinalIgnoreCase) { "pass", "password", "token", "secret", "apiKey", "api_key" };
public IReadOnlySet<string> SecretFieldsFor(string providerId) => Fields;
}

View File

@@ -0,0 +1,6 @@
using System.Text.Json;
namespace VolcanicCore.Api.Contracts;
public sealed record CreateConnectionRequest(string ProviderId, string Name, JsonElement Config);
public sealed record ConnectionResponse(string Id, string ProviderId, string Name, JsonElement Config);

View File

@@ -0,0 +1,53 @@
using System.Text.Json;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Endpoints;
public static class ConnectionsEndpoints
{
public static void MapConnectionsEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/connections").RequireAuthorization();
g.MapGet("", (ConnectionRepository repo, IConnectionSecretPolicy policy) =>
{
var rows = repo.List().Select(r => new ConnectionResponse(
r.Id, r.ProviderId, r.Name, StripSecrets(r.ConfigJson, policy.SecretFieldsFor(r.ProviderId))));
return Results.Ok(rows);
});
g.MapGet("/{id}", (string id, ConnectionRepository repo, IConnectionSecretPolicy policy) =>
{
var r = repo.Get(id);
return r is null
? Results.NotFound()
: Results.Ok(new ConnectionResponse(r.Id, r.ProviderId, r.Name,
StripSecrets(r.ConfigJson, policy.SecretFieldsFor(r.ProviderId))));
});
g.MapPost("", (CreateConnectionRequest req, ConnectionRepository repo, IConnectionSecretPolicy policy) =>
{
var id = repo.Create(req.ProviderId, req.Name, req.Config.GetRawText());
var stripped = StripSecrets(req.Config.GetRawText(), policy.SecretFieldsFor(req.ProviderId));
return Results.Created($"/api/connections/{id}",
new ConnectionResponse(id, req.ProviderId, req.Name, stripped));
});
g.MapDelete("/{id}", (string id, ConnectionRepository repo) =>
repo.Delete(id) ? Results.NoContent() : Results.NotFound());
}
private static JsonElement StripSecrets(string configJson, IReadOnlySet<string> secretFields)
{
using var doc = JsonDocument.Parse(configJson);
var stripped = new Dictionary<string, JsonElement>();
foreach (var p in doc.RootElement.EnumerateObject())
{
if (secretFields.Contains(p.Name)) continue;
stripped[p.Name] = p.Value.Clone();
}
return JsonSerializer.SerializeToElement(stripped);
}
}

View File

@@ -39,8 +39,11 @@ builder.Services.AddSingleton<AdminConfigRepository>();
builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
{
new Migration001Initial(),
new Migration002Connections(),
}));
builder.Services.AddSingleton<PasswordService>();
builder.Services.AddSingleton<ConnectionRepository>();
builder.Services.AddSingleton<IConnectionSecretPolicy, HardcodedSecretPolicy>();
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@@ -97,6 +100,7 @@ app.UseAuthorization();
app.MapSessionEndpoints();
app.MapNodeTypesEndpoints();
app.MapConnectionProvidersEndpoints();
app.MapConnectionsEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
app.Run();

View File

@@ -0,0 +1,24 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Migrations;
public sealed class Migration002Connections : IMigration
{
public int Version => 2;
public void Apply(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE connections (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
name TEXT NOT NULL,
config_json_encrypted TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX ux_connections_name_per_provider
ON connections (provider_id, name);
";
cmd.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence.Encryption;
namespace VolcanicCore.Persistence.Repositories;
public sealed record ConnectionRow(string Id, string ProviderId, string Name,
string ConfigJson, DateTimeOffset CreatedAt);
public sealed class ConnectionRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly EncryptionService _enc;
public ConnectionRepository(SqliteConnectionFactory factory, EncryptionService enc)
{ _factory = factory; _enc = enc; }
public string Create(string providerId, string name, string configJson)
{
var id = Guid.NewGuid().ToString("N");
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO connections (id, provider_id, name, config_json_encrypted, created_at)
VALUES ($id, $pid, $name, $enc, $at);
";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$pid", providerId);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$enc", _enc.Encrypt(configJson));
cmd.Parameters.AddWithValue("$at", DateTimeOffset.UtcNow.ToString("o"));
cmd.ExecuteNonQuery();
return id;
}
public ConnectionRow? Get(string id)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, provider_id, name, config_json_encrypted, created_at
FROM connections WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
return new ConnectionRow(
r.GetString(0), r.GetString(1), r.GetString(2),
_enc.Decrypt(r.GetString(3)),
DateTimeOffset.Parse(r.GetString(4)));
}
public IReadOnlyList<ConnectionRow> List()
{
var rows = new List<ConnectionRow>();
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, provider_id, name, config_json_encrypted, created_at
FROM connections ORDER BY created_at DESC;";
using var r = cmd.ExecuteReader();
while (r.Read())
rows.Add(new ConnectionRow(
r.GetString(0), r.GetString(1), r.GetString(2),
_enc.Decrypt(r.GetString(3)),
DateTimeOffset.Parse(r.GetString(4))));
return rows;
}
public bool Delete(string id)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"DELETE FROM connections WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
return cmd.ExecuteNonQuery() == 1;
}
}