< Summary

Information
Class: Elsa.Secrets.Services.DefaultSecretManager
Assembly: Elsa.Secrets
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Secrets/Services/DefaultSecretManager.cs
Line coverage
93%
Covered lines: 202
Uncovered lines: 15
Coverable lines: 217
Total lines: 389
Line coverage: 93%
Branch coverage
83%
Covered branches: 82
Total branches: 98
Branch coverage: 83.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CreateAsync()100%22100%
GetAsync()100%44100%
ListAsync()100%11100%
ListPageAsync()100%88100%
CountAsync()100%11100%
UpdateAsync()100%44100%
RotateAsync()87.5%88100%
RevokeAsync()75%4490%
DeleteAsync()100%22100%
TestAsync()50%2275%
ResolvePayloadAsync()100%210%
ResolvePayloadAsync()50%2283.33%
CreateGenerationAsync()50%2294.11%
ResolveGenerationAsync()100%11100%
DeleteGenerationAsync()83.33%6688.88%
ReadPayloadCoreAsync()50%4485.71%
DeleteGenerationAsync()100%11100%
CreateSecretAsync()83.33%6695.45%
GetExistingAsync()100%22100%
GetLatestActiveVersion(...)75%44100%
ValidateName(...)100%22100%
EnsureCanWrite(...)50%2266.66%
EnsureNotLifecycleManaged(...)100%22100%
EnsureManagedOwner(...)100%22100%
HasManagedOwner(...)100%22100%
ValidateManagedIdentity(...)50%10866.66%
ApplyFilters(...)88.88%1818100%
GetFilterValues(...)100%44100%
CreatePayload(...)100%22100%
CreatePayload(...)100%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Secrets/Services/DefaultSecretManager.cs

#LineLine coverage
 1namespace Elsa.Secrets.Services;
 2
 1463public class DefaultSecretManager(ISecretNameValidator nameValidator, ISecretStoreRegistry storeRegistry, ISecretTypeReg
 4{
 5    public async Task<Secret> CreateAsync(CreateSecretRequest request, CancellationToken cancellationToken = default)
 6    {
 487        ValidateName(request.Name);
 448        var secret = await CreateSecretAsync(request, cancellationToken);
 399        if (!await repository.TryAddOrReplaceDeletedAsync(secret, cancellationToken))
 310            throw new InvalidOperationException($"A secret named '{request.Name}' already exists.");
 11
 3612        return secret;
 3613    }
 14
 15    public async Task<Secret?> GetAsync(string name, CancellationToken cancellationToken = default)
 16    {
 8317        var secret = await repository.GetAsync(nameValidator.Normalize(name), cancellationToken);
 8318        return secret is { Status: SecretStatus.Deleted } ? null : secret;
 8319    }
 20
 21    public async Task<IReadOnlyCollection<Secret>> ListAsync(ListSecretsRequest request, CancellationToken cancellationT
 22    {
 923        return (await ListPageAsync(request, cancellationToken)).Items;
 924    }
 25
 26    public async Task<ListSecretsResult> ListPageAsync(ListSecretsRequest request, CancellationToken cancellationToken =
 27    {
 1628        var secrets = await repository.ListAsync(cancellationToken);
 1629        var query = ApplyFilters(secrets, request);
 1630        var totalCount = query.LongCount();
 31
 1632        var pageSize = request.PageSize is > 0 ? Math.Min(request.PageSize.Value, 200) : 100;
 1633        var page = request.Page is > 0 ? request.Page.Value : 0;
 34
 1635        var items = query
 36            .OrderBy(x => x.Name)
 1637            .Skip(page * pageSize)
 1638            .Take(pageSize)
 1639            .ToList();
 40
 1641        return new ListSecretsResult { Items = items, TotalCount = totalCount };
 1642    }
 43
 44    public async Task<long> CountAsync(ListSecretsRequest request, CancellationToken cancellationToken = default)
 45    {
 246        var secrets = await repository.ListAsync(cancellationToken);
 247        return ApplyFilters(secrets, request).LongCount();
 248    }
 49
 50    public async Task<Secret> UpdateAsync(string name, UpdateSecretRequest request, CancellationToken cancellationToken 
 51    {
 952        var secret = await GetExistingAsync(name, cancellationToken);
 653        EnsureNotLifecycleManaged(secret);
 54
 555        secret.DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? secret.Name : request.DisplayName.Trim();
 556        secret.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
 557        secret.UpdatedAt = DateTimeOffset.UtcNow;
 58
 559        await repository.SaveAsync(secret, cancellationToken);
 560        return secret;
 561    }
 62
 63    public async Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken 
 64    {
 965        var secret = await GetExistingAsync(name, cancellationToken);
 966        EnsureNotLifecycleManaged(secret);
 867        if (secret.Status == SecretStatus.Revoked)
 168            throw new InvalidOperationException($"Secret '{secret.Name}' is revoked and cannot be rotated.");
 69
 770        var provider = typeRegistry.Get(secret.TypeName);
 771        if (!provider.ValidateRotation(request, secret.StoreName, out var error))
 172            throw new InvalidOperationException(error);
 73
 674        var store = storeRegistry.Get(secret.StoreName);
 675        EnsureCanWrite(store);
 76        var nextVersion = secret.Versions.Count == 0 ? 1 : secret.Versions.Max(x => x.Version) + 1;
 677        var version = new SecretVersion { Version = nextVersion, ExpiresAt = request.ExpiresAt };
 678        version.Payload = await store.WriteAsync(secret, version, CreatePayload(request), cancellationToken);
 79
 80        foreach (var activeVersion in secret.Versions.Where(x => x.Status == SecretStatus.Active))
 681            activeVersion.Status = SecretStatus.Retired;
 82
 683        secret.Versions.Add(version);
 684        secret.Status = SecretStatus.Active;
 685        secret.UpdatedAt = DateTimeOffset.UtcNow;
 686        await repository.SaveAsync(secret, cancellationToken);
 87
 688        return secret;
 689    }
 90
 91    public async Task<Secret?> RevokeAsync(string name, CancellationToken cancellationToken = default)
 92    {
 793        var secret = await GetAsync(name, cancellationToken);
 794        if (secret == null)
 095            return null;
 96
 797        EnsureNotLifecycleManaged(secret);
 98
 699        secret.Status = SecretStatus.Revoked;
 6100        secret.UpdatedAt = DateTimeOffset.UtcNow;
 101        foreach (var version in secret.Versions.Where(x => x.Status == SecretStatus.Active))
 6102            version.Status = SecretStatus.Revoked;
 103
 6104        await repository.SaveAsync(secret, cancellationToken);
 6105        return secret;
 6106    }
 107
 108    public async Task<bool> DeleteAsync(string name, CancellationToken cancellationToken = default)
 109    {
 9110        var secret = await GetAsync(name, cancellationToken);
 9111        if (secret == null)
 2112            return false;
 113
 7114        EnsureNotLifecycleManaged(secret);
 115
 6116        await storeRegistry.Get(secret.StoreName).DeleteAsync(secret, cancellationToken);
 6117        secret.Status = SecretStatus.Deleted;
 6118        secret.UpdatedAt = DateTimeOffset.UtcNow;
 6119        await repository.SaveAsync(secret, cancellationToken);
 6120        return true;
 8121    }
 122
 123    public async Task<SecretTestResponse> TestAsync(string name, CancellationToken cancellationToken = default)
 124    {
 125        try
 126        {
 12127            var secret = await GetExistingAsync(name, cancellationToken);
 8128            EnsureNotLifecycleManaged(secret);
 7129            var version = GetLatestActiveVersion(secret);
 7130            var succeeded = await storeRegistry.Get(secret.StoreName).TestAsync(secret, version, cancellationToken);
 6131            return new SecretTestResponse { Succeeded = succeeded, Error = succeeded ? null : "Secret value is unavailab
 132        }
 1133        catch (InvalidOperationException e)
 134        {
 1135            return new SecretTestResponse { Succeeded = false, Error = e.Message };
 136        }
 4137        catch (KeyNotFoundException e)
 138        {
 4139            return new SecretTestResponse { Succeeded = false, Error = e.Message };
 140        }
 0141        catch (ArgumentException e)
 142        {
 0143            return new SecretTestResponse { Succeeded = false, Error = e.Message };
 144        }
 0145        catch (System.Security.Cryptography.CryptographicException e)
 146        {
 0147            return new SecretTestResponse { Succeeded = false, Error = e.Message };
 148        }
 1149        catch (FormatException e)
 150        {
 1151            return new SecretTestResponse { Succeeded = false, Error = e.Message };
 152        }
 12153    }
 154
 155    public async Task<SecretPayload> ResolvePayloadAsync(string name, CancellationToken cancellationToken = default)
 156    {
 0157        var secret = await GetExistingAsync(name, cancellationToken);
 0158        return await ResolvePayloadAsync(secret, cancellationToken);
 0159    }
 160
 161    public async Task<SecretPayload> ResolvePayloadAsync(Secret secret, CancellationToken cancellationToken = default)
 162    {
 9163        var persisted = await GetExistingAsync(secret.Name, cancellationToken);
 9164        if (!string.Equals(secret.Id, persisted.Id, StringComparison.Ordinal))
 165        {
 0166            throw new InvalidOperationException($"Secret '{secret.Name}' was not found.");
 167        }
 168
 9169        EnsureNotLifecycleManaged(persisted);
 8170        return await ReadPayloadCoreAsync(persisted, cancellationToken);
 7171    }
 172
 173    public async Task<Secret> CreateGenerationAsync(string ownerId, string generationId, string value, CancellationToken
 174    {
 5175        ValidateManagedIdentity(ownerId, generationId);
 5176        var name = ManagedSecretNames.ForGeneration(ownerId, generationId);
 5177        if (await repository.GetAsync(nameValidator.Normalize(name), cancellationToken) != null)
 178        {
 0179            throw new InvalidOperationException($"A secret named '{name}' already exists.");
 180        }
 181
 5182        var request = new CreateSecretRequest
 5183        {
 5184            Name = name,
 5185            TypeName = SecretTypeNames.Text,
 5186            StoreName = SecretStoreNames.Encrypted,
 5187            Value = value
 5188        };
 189
 5190        var secret = await CreateSecretAsync(request, cancellationToken);
 5191        secret.ManagedOwnerId = ownerId;
 5192        secret.ManagedGenerationId = generationId;
 5193        await repository.AddAsync(secret, cancellationToken);
 5194        return secret;
 5195    }
 196
 197    public async Task<SecretPayload> ResolveGenerationAsync(string name, string ownerId, string generationId, Cancellati
 198    {
 2199        ValidateManagedIdentity(ownerId, generationId);
 2200        var secret = await GetExistingAsync(name, cancellationToken);
 2201        EnsureManagedOwner(secret, ownerId, generationId);
 1202        return await ReadPayloadCoreAsync(secret, cancellationToken);
 1203    }
 204
 205    public async Task<bool> DeleteGenerationAsync(string name, string ownerId, string generationId, CancellationToken ca
 206    {
 3207        ValidateManagedIdentity(ownerId, generationId);
 208        // Read the repository directly so a retry after process loss can recognize its own tombstone. The
 209        // public GetAsync intentionally hides deleted secrets, while immutable managed-generation identities
 210        // must remain unavailable for recreation after cleanup.
 3211        var secret = await repository.GetAsync(nameValidator.Normalize(name), cancellationToken);
 3212        if (secret == null || !HasManagedOwner(secret, ownerId, generationId))
 213        {
 1214            return false;
 215        }
 216
 2217        if (secret.Status == SecretStatus.Deleted)
 218        {
 0219            return true;
 220        }
 221
 2222        await DeleteGenerationAsync(secret, cancellationToken);
 2223        return true;
 3224    }
 225
 226    private async Task<SecretPayload> ReadPayloadCoreAsync(Secret secret, CancellationToken cancellationToken)
 227    {
 9228        var version = GetLatestActiveVersion(secret);
 8229        var store = storeRegistry.Get(secret.StoreName);
 8230        var payload = await store.ReadAsync(secret, version, cancellationToken);
 231
 8232        if (payload?.Value == null)
 0233            throw new InvalidOperationException($"Secret '{secret.Name}' could not be resolved.");
 234
 8235        return payload;
 8236    }
 237
 238    private async Task DeleteGenerationAsync(Secret secret, CancellationToken cancellationToken)
 239    {
 2240        await storeRegistry.Get(secret.StoreName).DeleteAsync(secret, cancellationToken);
 2241        secret.Status = SecretStatus.Deleted;
 2242        secret.UpdatedAt = DateTimeOffset.UtcNow;
 2243        await repository.SaveAsync(secret, cancellationToken);
 2244    }
 245
 246    private async Task<Secret> CreateSecretAsync(CreateSecretRequest request, CancellationToken cancellationToken)
 247    {
 49248        var typeProvider = typeRegistry.Get(request.TypeName);
 49249        var store = storeRegistry.Get(request.StoreName);
 49250        EnsureCanWrite(store);
 251
 49252        if (!typeProvider.Descriptor.SupportedStoreNames.Contains(store.Name, StringComparer.OrdinalIgnoreCase))
 0253            throw new InvalidOperationException($"Secret type '{request.TypeName}' does not support store '{request.Stor
 254
 49255        if (!typeProvider.Validate(request, out var error))
 5256            throw new InvalidOperationException(error);
 257
 44258        var secret = new Secret
 44259        {
 44260            Name = nameValidator.Normalize(request.Name),
 44261            DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? request.Name.Trim() : request.DisplayName.Tri
 44262            Description = request.Description,
 44263            TypeName = typeProvider.Descriptor.Name,
 44264            StoreName = store.Name,
 44265            Scope = request.Scope,
 44266            Tags = request.Tags.ToHashSet(StringComparer.OrdinalIgnoreCase)
 44267        };
 268
 44269        var version = new SecretVersion { Version = 1, ExpiresAt = request.ExpiresAt };
 44270        version.Payload = await store.WriteAsync(secret, version, CreatePayload(request), cancellationToken);
 44271        secret.Versions.Add(version);
 272
 44273        return secret;
 44274    }
 275
 276    private async Task<Secret> GetExistingAsync(string name, CancellationToken cancellationToken)
 277    {
 41278        var secret = await GetAsync(name, cancellationToken);
 41279        return secret == null ? throw new KeyNotFoundException($"Secret '{name}' was not found.") : secret;
 34280    }
 281
 282    private static SecretVersion GetLatestActiveVersion(Secret secret)
 283    {
 16284        if (secret.Status != SecretStatus.Active)
 1285            throw new InvalidOperationException($"Secret '{secret.Name}' is not active.");
 286
 15287        return secret.LatestActiveVersion ?? throw new InvalidOperationException($"Secret '{secret.Name}' has no active 
 288    }
 289
 290    private void ValidateName(string name)
 291    {
 48292        if (!nameValidator.IsValid(name, out var error))
 4293            throw new InvalidOperationException(error);
 44294    }
 295
 296    private static void EnsureCanWrite(ISecretStore store)
 297    {
 55298        if (!store.Descriptor.Capabilities.HasFlag(SecretStoreCapabilities.Write))
 0299            throw new InvalidOperationException($"Secret store '{store.Name}' does not support writing secrets.");
 55300    }
 301
 302    private static void EnsureNotLifecycleManaged(Secret secret)
 303    {
 46304        if (secret.IsLifecycleManaged)
 305        {
 6306            throw new InvalidOperationException("Lifecycle-managed secret generations can only be accessed through their
 307        }
 40308    }
 309
 310    private static void EnsureManagedOwner(Secret secret, string ownerId, string generationId)
 311    {
 2312        if (!HasManagedOwner(secret, ownerId, generationId))
 313        {
 1314            throw new InvalidOperationException("Managed credential generation is unavailable.");
 315        }
 1316    }
 317
 318    private static bool HasManagedOwner(Secret secret, string ownerId, string generationId)
 319    {
 5320        return string.Equals(secret.ManagedOwnerId, ownerId, StringComparison.Ordinal) &&
 5321               string.Equals(secret.ManagedGenerationId, generationId, StringComparison.Ordinal);
 322    }
 323
 324    private static void ValidateManagedIdentity(string ownerId, string generationId)
 325    {
 10326        if (string.IsNullOrWhiteSpace(ownerId) || ownerId.Length > 200 || string.IsNullOrWhiteSpace(generationId) || gen
 327        {
 0328            throw new ArgumentException("A valid owner and generation identifier are required.");
 329        }
 10330    }
 331
 332    private static IEnumerable<Secret> ApplyFilters(IEnumerable<Secret> secrets, ListSecretsRequest request)
 333    {
 57334        var query = secrets.Where(x => x.Status != SecretStatus.Deleted && !x.IsLifecycleManaged);
 335
 18336        if (!string.IsNullOrWhiteSpace(request.Search))
 337        {
 2338            var search = request.Search.Trim();
 2339            query = query.Where(x =>
 12340                x.Name.Contains(search, StringComparison.OrdinalIgnoreCase) ||
 12341                x.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
 12342                (x.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false));
 343        }
 344
 18345        var typeNames = GetFilterValues(request.TypeName, request.TypeNames);
 18346        if (typeNames.Count > 0)
 23347            query = query.Where(x => typeNames.Contains(x.TypeName));
 348
 18349        var storeNames = GetFilterValues(request.StoreName, request.StoreNames);
 18350        if (storeNames.Count > 0)
 23351            query = query.Where(x => storeNames.Contains(x.StoreName));
 352
 18353        if (!string.IsNullOrWhiteSpace(request.Scope))
 23354            query = query.Where(x => string.Equals(x.Scope, request.Scope, StringComparison.OrdinalIgnoreCase));
 355
 18356        if (request.Status != null)
 24357            query = query.Where(x => x.Status == request.Status);
 358
 18359        return query;
 360    }
 361
 362    private static HashSet<string> GetFilterValues(string? value, IEnumerable<string> values)
 363    {
 36364        var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 36365        if (!string.IsNullOrWhiteSpace(value))
 2366            result.Add(value.Trim());
 367
 108368        foreach (var item in values.Where(x => !string.IsNullOrWhiteSpace(x)))
 12369            result.Add(item.Trim());
 370
 36371        return result;
 372    }
 373
 374    private static SecretPayload CreatePayload(CreateSecretRequest request)
 375    {
 44376        var payload = new SecretPayload { Value = request.Value, Metadata = new Dictionary<string, string>(request.Metad
 44377        if (!string.IsNullOrWhiteSpace(request.ConfigurationKey))
 4378            payload.Metadata["configurationKey"] = request.ConfigurationKey.Trim();
 44379        return payload;
 380    }
 381
 382    private static SecretPayload CreatePayload(RotateSecretRequest request)
 383    {
 6384        var payload = new SecretPayload { Value = request.Value, Metadata = new Dictionary<string, string>(request.Metad
 6385        if (!string.IsNullOrWhiteSpace(request.ConfigurationKey))
 1386            payload.Metadata["configurationKey"] = request.ConfigurationKey.Trim();
 6387        return payload;
 388    }
 389}