186 lines
6.5 KiB
C#
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}'");
|
|
}
|