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; /// Raised when a task references a credential the tenant does not have. 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; } } /// /// The tenant credential vault: tenant-scoped CRUD over encrypted payloads plus /// the run-time resolver that turns a step's { alias: reference } map into /// decrypted . 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. /// 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 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 GetAsync(WorkflowsDbContext db, string tenantId, Guid id, CancellationToken ct) => db.Credentials.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId, ct); public async Task> ListAsync( WorkflowsDbContext db, string tenantId, CancellationToken ct) => await db.Credentials .Where(c => c.TenantId == tenantId) .OrderBy(c => c.Name) .ToListAsync(ct); public async Task 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 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; } /// Decrypts a stored credential's payload. Never logs or returns the ciphertext. public JsonObject Decrypt(Credential entity) => JsonNode.Parse(_cipher.Unprotect(entity.EncryptedData)) as JsonObject ?? new JsonObject(); /// /// Resolves a step's alias → reference map into decrypted credential data. /// A reference is a credential id (Guid) or its unique name. /// public async Task> ResolveAsync( WorkflowsDbContext db, string tenantId, IReadOnlyDictionary references, CancellationToken ct) { var resolved = new Dictionary(StringComparer.Ordinal); if (references.Count == 0) return resolved; var ids = new List(); var names = new List(); 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(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}'"); }