w4c-workflows-api/Services/Credentials/CredentialVault.cs
2026-09-12 01:02:46 +03:00

186 lines
6.5 KiB
C#

using System.Text.Json.Nodes;
using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Models;
using w4c_workflows.Models.Credentials;
namespace w4c_workflows.Services.Credentials;
/// <summary>Raised when a task references a credential the tenant does not have.</summary>
public sealed class CredentialResolutionException : Exception
{
public CredentialResolutionException(string alias, string reference)
: base($"credential '{reference}' (alias '{alias}') could not be resolved for this tenant")
{
Alias = alias;
Reference = reference;
}
public string Alias { get; }
public string Reference { get; }
}
/// <summary>
/// The tenant credential vault: tenant-scoped CRUD over encrypted payloads plus
/// the run-time resolver that turns a step's <c>{ alias: reference }</c> map into
/// decrypted <see cref="CredentialData"/>. Decryption happens only in memory.
///
/// The vault is a stateless singleton; each method takes the (scoped) DbContext
/// so it can be used from both controllers and the run loop without lifetime
/// coupling.
/// </summary>
public sealed class CredentialVault
{
private readonly ICredentialCipher _cipher;
private readonly CredentialTypeCatalog _types;
public CredentialVault(ICredentialCipher cipher, CredentialTypeCatalog types)
{
_cipher = cipher;
_types = types;
}
public async Task<Credential> CreateAsync(
WorkflowsDbContext db, string tenantId, string name, string type, JsonObject data, CancellationToken ct)
{
Validate(type, data);
if (await db.Credentials.AnyAsync(c => c.TenantId == tenantId && c.Name == name, ct))
throw new InvalidOperationException($"a credential named '{name}' already exists");
var now = DateTime.UtcNow;
var entity = new Credential
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = name,
Type = type,
EncryptedData = _cipher.Protect(data.ToJsonString()),
CreatedAt = now,
UpdatedAt = now,
};
db.Credentials.Add(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public Task<Credential?> GetAsync(WorkflowsDbContext db, string tenantId, Guid id, CancellationToken ct)
=> db.Credentials.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId, ct);
public async Task<IReadOnlyList<Credential>> ListAsync(
WorkflowsDbContext db, string tenantId, CancellationToken ct)
=> await db.Credentials
.Where(c => c.TenantId == tenantId)
.OrderBy(c => c.Name)
.ToListAsync(ct);
public async Task<Credential?> UpdateAsync(
WorkflowsDbContext db, string tenantId, Guid id, string? name, string? type, JsonObject? data, CancellationToken ct)
{
var entity = await GetAsync(db, tenantId, id, ct);
if (entity == null)
return null;
if (!string.IsNullOrWhiteSpace(name) && !string.Equals(name, entity.Name, StringComparison.Ordinal))
{
if (await db.Credentials.AnyAsync(c => c.TenantId == tenantId && c.Name == name && c.Id != id, ct))
throw new InvalidOperationException($"a credential named '{name}' already exists");
entity.Name = name;
}
if (!string.IsNullOrWhiteSpace(type))
{
RequireType(type);
entity.Type = type;
}
if (data != null)
{
Validate(entity.Type, data);
entity.EncryptedData = _cipher.Protect(data.ToJsonString());
}
entity.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return entity;
}
public async Task<bool> DeleteAsync(WorkflowsDbContext db, string tenantId, Guid id, CancellationToken ct)
{
var entity = await GetAsync(db, tenantId, id, ct);
if (entity == null)
return false;
db.Credentials.Remove(entity);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>Decrypts a stored credential's payload. Never logs or returns the ciphertext.</summary>
public JsonObject Decrypt(Credential entity)
=> JsonNode.Parse(_cipher.Unprotect(entity.EncryptedData)) as JsonObject ?? new JsonObject();
/// <summary>
/// Resolves a step's alias → reference map into decrypted credential data.
/// A reference is a credential id (Guid) or its unique name.
/// </summary>
public async Task<IReadOnlyDictionary<string, CredentialData>> ResolveAsync(
WorkflowsDbContext db,
string tenantId,
IReadOnlyDictionary<string, string> references,
CancellationToken ct)
{
var resolved = new Dictionary<string, CredentialData>(StringComparer.Ordinal);
if (references.Count == 0)
return resolved;
var ids = new List<Guid>();
var names = new List<string>();
foreach (var reference in references.Values)
{
if (Guid.TryParse(reference, out var id))
ids.Add(id);
else
names.Add(reference);
}
var found = await db.Credentials
.Where(c => c.TenantId == tenantId && (ids.Contains(c.Id) || names.Contains(c.Name)))
.ToListAsync(ct);
foreach (var (alias, reference) in references)
{
var match = Guid.TryParse(reference, out var id)
? found.FirstOrDefault(c => c.Id == id)
: found.FirstOrDefault(c => string.Equals(c.Name, reference, StringComparison.Ordinal));
if (match == null)
throw new CredentialResolutionException(alias, reference);
resolved[alias] = new CredentialData(match.Type, Decrypt(match));
}
return resolved;
}
private void Validate(string type, JsonObject data)
{
var descriptor = RequireType(type);
foreach (var field in descriptor.Fields.Where(f => f.Required))
{
if (data[field.Name] is not JsonValue value
|| !value.TryGetValue<string>(out var text)
|| string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException(
$"credential field '{field.Name}' is required for type '{type}'");
}
}
}
private CredentialType RequireType(string type)
=> _types.Get(type) ?? throw new InvalidOperationException($"unknown credential type '{type}'");
}