feat(api): connections CRUD with encrypted storage + secret masking
This commit is contained in:
13
src/VolcanicCore.Api/Auth/HardcodedSecretPolicy.cs
Normal file
13
src/VolcanicCore.Api/Auth/HardcodedSecretPolicy.cs
Normal 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;
|
||||
}
|
||||
6
src/VolcanicCore.Api/Contracts/ConnectionDtos.cs
Normal file
6
src/VolcanicCore.Api/Contracts/ConnectionDtos.cs
Normal 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);
|
||||
53
src/VolcanicCore.Api/Endpoints/ConnectionsEndpoints.cs
Normal file
53
src/VolcanicCore.Api/Endpoints/ConnectionsEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user