diff --git a/src/Integrations/StellaOps.Integrations.WebService/Program.cs b/src/Integrations/StellaOps.Integrations.WebService/Program.cs index 0d116d66e..dc3a81695 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Program.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Program.cs @@ -118,6 +118,7 @@ builder.Services.AddScoped(); // Core service builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -132,6 +133,8 @@ builder.Services.AddAuthorization(options => options.AddStellaOpsScopePolicy(IntegrationPolicies.Read, StellaOpsScopes.IntegrationRead); options.AddStellaOpsScopePolicy(IntegrationPolicies.Write, StellaOpsScopes.IntegrationWrite); options.AddStellaOpsScopePolicy(IntegrationPolicies.Operate, StellaOpsScopes.IntegrationOperate); + options.AddStellaOpsScopePolicy(IntegrationPolicies.SecretAuthorityRead, StellaOpsScopes.IntegrationRead); + options.AddStellaOpsScopePolicy(IntegrationPolicies.SecretAuthorityWrite, StellaOpsScopes.IntegrationWrite); }); // Unified audit emission (posts audit events to Timeline service) @@ -165,6 +168,7 @@ app.TryUseStellaRouter(routerEnabled); // Map endpoints app.MapIntegrationEndpoints(); +app.MapSecretAuthorityEndpoints(); // Health endpoint app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow })) diff --git a/src/Integrations/StellaOps.Integrations.WebService/SecretAuthorityEndpoints.cs b/src/Integrations/StellaOps.Integrations.WebService/SecretAuthorityEndpoints.cs new file mode 100644 index 000000000..9ff49a46e --- /dev/null +++ b/src/Integrations/StellaOps.Integrations.WebService/SecretAuthorityEndpoints.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using StellaOps.Audit.Emission; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.WebService.Security; + +namespace StellaOps.Integrations.WebService; + +public static class SecretAuthorityEndpoints +{ + public static void MapSecretAuthorityEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/v1/secret-authority") + .RequireAuthorization(IntegrationPolicies.SecretAuthorityRead) + .RequireTenant() + .WithTags("Secret Authority"); + + group.MapGet("/targets", async ( + [FromServices] SecretAuthorityService service, + [FromServices] IStellaOpsTenantAccessor tenantAccessor, + CancellationToken cancellationToken) => + { + var result = await service.ListTargetsAsync(tenantAccessor.TenantId, cancellationToken); + return Results.Ok(result); + }) + .RequireAuthorization(IntegrationPolicies.SecretAuthorityRead) + .WithName("ListSecretAuthorityTargets") + .WithDescription("List tenant-visible secrets-manager integrations that can stage authref-backed credential bundles."); + + group.MapPut("/bundles/{bundleId}", async ( + [FromServices] SecretAuthorityService service, + [FromServices] IStellaOpsTenantAccessor tenantAccessor, + string bundleId, + [FromBody] UpsertSecretBundleRequest request, + CancellationToken cancellationToken) => + { + try + { + var result = await service.UpsertBundleAsync(bundleId, request, tenantAccessor.TenantId, cancellationToken); + return Results.Ok(result); + } + catch (SecretAuthorityException ex) + { + return Results.Problem( + title: "Secret bundle staging failed", + detail: ex.Message, + statusCode: ex.StatusCode, + extensions: new Dictionary + { + ["errorCode"] = ex.ErrorCode + }); + } + }) + .RequireAuthorization(IntegrationPolicies.SecretAuthorityWrite) + .WithName("UpsertSecretAuthorityBundle") + .WithDescription("Write or update a credential bundle in the selected secret authority and return authref bindings.") + .Audited(AuditModules.Integrations, AuditActions.Integrations.UpsertSecretBundle); + } +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/SecretAuthorityService.cs b/src/Integrations/StellaOps.Integrations.WebService/SecretAuthorityService.cs new file mode 100644 index 000000000..4016fabfe --- /dev/null +++ b/src/Integrations/StellaOps.Integrations.WebService/SecretAuthorityService.cs @@ -0,0 +1,448 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using StellaOps.Integrations.Persistence; +using StellaOps.Integrations.WebService.Infrastructure; + +namespace StellaOps.Integrations.WebService; + +public sealed class SecretAuthorityService +{ + private const string VaultAuthRefPrefix = "authref://vault/"; + private const string VaultKvMount = "secret"; + private const string DefaultVaultToken = "stellaops-dev-root-token-2026"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly IIntegrationRepository _repository; + private readonly IAuthRefResolver _authRefResolver; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public SecretAuthorityService( + IIntegrationRepository repository, + IAuthRefResolver authRefResolver, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _repository = repository; + _authRefResolver = authRefResolver; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task ListTargetsAsync(string? tenantId, CancellationToken cancellationToken) + { + var targets = await _repository.GetAllAsync( + new IntegrationQuery( + Type: IntegrationType.SecretsManager, + TenantId: tenantId, + Take: 100, + SortBy: "name"), + cancellationToken).ConfigureAwait(false); + + var items = targets + .OrderBy(target => target.Name, StringComparer.Ordinal) + .Select(MapTarget) + .ToArray(); + + return new SecretAuthorityTargetsResponse(items); + } + + public async Task UpsertBundleAsync( + string bundleId, + UpsertSecretBundleRequest request, + string? tenantId, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bundleId); + ArgumentNullException.ThrowIfNull(request); + + ValidateEntries(request.Entries); + + var target = await _repository.GetByIdAsync(request.TargetIntegrationId, cancellationToken).ConfigureAwait(false); + if (target is null || target.IsDeleted || !string.Equals(target.TenantId, tenantId, StringComparison.Ordinal)) + { + throw SecretAuthorityException.NotFound( + "target_not_found", + $"Secret authority target '{request.TargetIntegrationId}' was not found for the current tenant."); + } + + if (target.Type != IntegrationType.SecretsManager) + { + throw SecretAuthorityException.BadRequest( + "invalid_target_type", + $"Integration '{target.Name}' is '{target.Type}', not a secrets-manager target."); + } + + return target.Provider switch + { + IntegrationProvider.Vault => await UpsertVaultBundleAsync(bundleId, request, target, cancellationToken).ConfigureAwait(false), + _ => throw SecretAuthorityException.NotImplemented( + "provider_not_supported", + $"Secrets provider '{target.Provider}' does not support staged bundle writes in this release.") + }; + } + + private async Task UpsertVaultBundleAsync( + string bundleId, + UpsertSecretBundleRequest request, + Integration target, + CancellationToken cancellationToken) + { + EnsureSupportedVaultMount(target); + + var logicalPath = NormalizeLogicalPath(bundleId, request.LogicalPath); + var token = await ResolveVaultTokenAsync(target, cancellationToken).ConfigureAwait(false); + var client = _httpClientFactory.CreateClient(VaultAuthRefResolver.HttpClientName); + + if (!request.Overwrite && + await VaultSecretExistsAsync(client, token, logicalPath, cancellationToken).ConfigureAwait(false)) + { + throw new SecretAuthorityException( + StatusCodes.Status409Conflict, + "bundle_exists", + $"Secret bundle '{logicalPath}' already exists in Vault and overwrite was disabled."); + } + + var payload = request.Entries.ToDictionary( + entry => entry.Key.Trim(), + entry => entry.Value ?? string.Empty, + StringComparer.Ordinal); + + await WriteVaultSecretAsync(client, token, logicalPath, payload, cancellationToken).ConfigureAwait(false); + if (request.Labels is { Count: > 0 }) + { + await WriteVaultMetadataAsync(client, token, logicalPath, request.Labels, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "Staged secret bundle {LogicalPath} for target {IntegrationId} with keys [{Keys}]", + logicalPath, + target.Id, + string.Join(", ", payload.Keys.OrderBy(key => key, StringComparer.Ordinal))); + + var authRefs = payload.Keys + .OrderBy(key => key, StringComparer.Ordinal) + .ToDictionary( + key => key, + key => $"{VaultAuthRefPrefix}{logicalPath}#{key}", + StringComparer.Ordinal); + + return new UpsertSecretBundleResponse( + target.Id, + bundleId.Trim(), + logicalPath, + authRefs, + target.Provider, + target.Endpoint); + } + + private SecretAuthorityTargetResponse MapTarget(Integration target) + { + return new SecretAuthorityTargetResponse( + target.Id, + target.Name, + target.Provider, + target.Endpoint, + MapTargetStatus(target), + Slugify(target.Name), + target.Provider == IntegrationProvider.Vault); + } + + private static string MapTargetStatus(Integration target) + { + if (target.Status is IntegrationStatus.Disabled or IntegrationStatus.Archived) + { + return "disabled"; + } + + if (target.Status == IntegrationStatus.Failed || target.LastHealthStatus == HealthStatus.Unhealthy) + { + return "unhealthy"; + } + + if (target.LastHealthStatus == HealthStatus.Degraded) + { + return "degraded"; + } + + if (target.LastHealthStatus == HealthStatus.Healthy || target.Status == IntegrationStatus.Active) + { + return "healthy"; + } + + return "pending"; + } + + private static void ValidateEntries(IReadOnlyList? entries) + { + if (entries is null || entries.Count == 0) + { + throw SecretAuthorityException.BadRequest( + "entries_required", + "At least one secret bundle entry is required."); + } + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var entry in entries) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.Key)) + { + throw SecretAuthorityException.BadRequest( + "invalid_entry_key", + "Secret bundle entry keys must be non-empty."); + } + + var key = entry.Key.Trim(); + if (!keys.Add(key)) + { + throw SecretAuthorityException.BadRequest( + "duplicate_entry_key", + $"Secret bundle entry key '{key}' was provided more than once."); + } + } + } + + private async Task ResolveVaultTokenAsync(Integration target, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(target.AuthRefUri)) + { + var resolved = await _authRefResolver.ResolveAsync(target.AuthRefUri, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(resolved)) + { + throw new SecretAuthorityException( + StatusCodes.Status502BadGateway, + "vault_token_resolution_failed", + $"Failed to resolve the Vault authref configured on target '{target.Name}'."); + } + + return resolved.Trim(); + } + + var fallback = Environment.GetEnvironmentVariable("VAULT_TOKEN"); + return string.IsNullOrWhiteSpace(fallback) ? DefaultVaultToken : fallback.Trim(); + } + + private async Task VaultSecretExistsAsync( + HttpClient client, + string token, + string logicalPath, + CancellationToken cancellationToken) + { + using var request = CreateVaultRequest(HttpMethod.Get, $"{VaultKvMount}/data/{logicalPath}", token); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + + return response.StatusCode switch + { + HttpStatusCode.OK => true, + HttpStatusCode.NotFound => false, + _ => throw await CreateVaultFailureAsync( + response, + "vault_secret_lookup_failed", + $"Failed to check whether Vault bundle '{logicalPath}' already exists.", + cancellationToken).ConfigureAwait(false) + }; + } + + private async Task WriteVaultSecretAsync( + HttpClient client, + string token, + string logicalPath, + IReadOnlyDictionary payload, + CancellationToken cancellationToken) + { + using var request = CreateVaultRequest(HttpMethod.Post, $"{VaultKvMount}/data/{logicalPath}", token); + request.Content = JsonContent.Create(new { data = payload }, options: JsonOptions); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw await CreateVaultFailureAsync( + response, + "vault_write_failed", + $"Failed to write secret bundle '{logicalPath}' to Vault.", + cancellationToken).ConfigureAwait(false); + } + } + + private async Task WriteVaultMetadataAsync( + HttpClient client, + string token, + string logicalPath, + IReadOnlyDictionary labels, + CancellationToken cancellationToken) + { + using var request = CreateVaultRequest(HttpMethod.Post, $"{VaultKvMount}/metadata/{logicalPath}", token); + request.Content = JsonContent.Create( + new + { + custom_metadata = labels + }, + options: JsonOptions); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw await CreateVaultFailureAsync( + response, + "vault_metadata_write_failed", + $"Failed to attach metadata to Vault bundle '{logicalPath}'.", + cancellationToken).ConfigureAwait(false); + } + } + + private static HttpRequestMessage CreateVaultRequest(HttpMethod method, string relativePath, string token) + { + var request = new HttpRequestMessage(method, $"/v1/{relativePath.TrimStart('/')}"); + request.Headers.TryAddWithoutValidation("X-Vault-Token", token); + return request; + } + + private static async Task CreateVaultFailureAsync( + HttpResponseMessage response, + string errorCode, + string message, + CancellationToken cancellationToken) + { + var detail = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return new SecretAuthorityException( + StatusCodes.Status502BadGateway, + errorCode, + $"{message} Vault returned {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".Trim()); + } + + private static void EnsureSupportedVaultMount(Integration target) + { + if (string.IsNullOrWhiteSpace(target.ConfigJson)) + { + return; + } + + try + { + using var document = JsonDocument.Parse(target.ConfigJson); + if (TryReadMountPath(document.RootElement, out var mountPath) && + !string.Equals(mountPath, VaultKvMount, StringComparison.OrdinalIgnoreCase)) + { + throw SecretAuthorityException.NotImplemented( + "vault_mount_not_supported", + $"Vault mount '{mountPath}' is not supported by Secret Authority yet. Use the '{VaultKvMount}' KV v2 mount."); + } + } + catch (JsonException) + { + throw SecretAuthorityException.BadRequest( + "invalid_target_config", + $"Secrets-manager target '{target.Name}' has invalid JSON configuration."); + } + } + + private static bool TryReadMountPath(JsonElement element, out string? mountPath) + { + mountPath = null; + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (var propertyName in new[] { "mountPath", "mount", "kvMountPath" }) + { + if (element.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString())) + { + mountPath = value.GetString()!.Trim(); + return true; + } + } + + return false; + } + + private static string NormalizeLogicalPath(string bundleId, string? logicalPath) + { + var source = string.IsNullOrWhiteSpace(logicalPath) ? bundleId : logicalPath!; + var segments = source + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(NormalizePathSegment) + .Where(segment => segment.Length > 0) + .ToArray(); + + if (segments.Length == 0) + { + throw SecretAuthorityException.BadRequest( + "invalid_logical_path", + "Secret bundle logicalPath must contain at least one valid path segment."); + } + + return string.Join('/', segments); + } + + private static string NormalizePathSegment(string segment) + { + if (segment is "." or "..") + { + throw SecretAuthorityException.BadRequest( + "invalid_logical_path", + "Secret bundle logicalPath cannot contain '.' or '..' segments."); + } + + var builder = new StringBuilder(segment.Length); + var previousDash = false; + foreach (var character in segment.Trim()) + { + if (char.IsLetterOrDigit(character) || character is '-' or '_' or '.') + { + builder.Append(char.ToLowerInvariant(character)); + previousDash = false; + continue; + } + + if (previousDash) + { + continue; + } + + builder.Append('-'); + previousDash = true; + } + + return builder.ToString().Trim('-'); + } + + private static string Slugify(string value) + { + var slug = NormalizePathSegment(value); + return string.IsNullOrWhiteSpace(slug) ? "secret-bundle" : slug; + } +} + +public sealed class SecretAuthorityException : InvalidOperationException +{ + public SecretAuthorityException(int statusCode, string errorCode, string message) + : base(message) + { + StatusCode = statusCode; + ErrorCode = errorCode; + } + + public int StatusCode { get; } + + public string ErrorCode { get; } + + public static SecretAuthorityException BadRequest(string errorCode, string message) => + new(StatusCodes.Status400BadRequest, errorCode, message); + + public static SecretAuthorityException NotFound(string errorCode, string message) => + new(StatusCodes.Status404NotFound, errorCode, message); + + public static SecretAuthorityException NotImplemented(string errorCode, string message) => + new(StatusCodes.Status501NotImplemented, errorCode, message); +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/Security/IntegrationPolicies.cs b/src/Integrations/StellaOps.Integrations.WebService/Security/IntegrationPolicies.cs index fe4f3e1ad..65502f0ef 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Security/IntegrationPolicies.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Security/IntegrationPolicies.cs @@ -16,4 +16,10 @@ internal static class IntegrationPolicies /// Policy for executing integration operations (test connections, AI Code Guard runs). Requires integration:operate scope. public const string Operate = "Integration.Operate"; + + /// Policy for reading secret-authority targets. Currently mapped to integration:read. + public const string SecretAuthorityRead = "Integration.SecretAuthority.Read"; + + /// Policy for staging secret-authority bundles. Currently mapped to integration:write. + public const string SecretAuthorityWrite = "Integration.SecretAuthority.Write"; } diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/SecretAuthorityDtos.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/SecretAuthorityDtos.cs new file mode 100644 index 000000000..7c4e470b8 --- /dev/null +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/SecretAuthorityDtos.cs @@ -0,0 +1,49 @@ +using StellaOps.Integrations.Core; + +namespace StellaOps.Integrations.Contracts; + +/// +/// Describes a secret-authority target that can accept staged credential bundles. +/// +public sealed record SecretAuthorityTargetResponse( + Guid IntegrationId, + string Name, + IntegrationProvider Provider, + string Endpoint, + string Status, + string LogicalPathHint, + bool SupportsWrite); + +/// +/// Lists secret-authority targets visible to the current tenant. +/// +public sealed record SecretAuthorityTargetsResponse( + IReadOnlyList Items); + +/// +/// Single secret entry staged into a bundle. +/// +public sealed record SecretBundleEntryRequest( + string Key, + string Value); + +/// +/// Writes or updates a staged credential bundle in the selected secret authority. +/// +public sealed record UpsertSecretBundleRequest( + Guid TargetIntegrationId, + string? LogicalPath, + IReadOnlyList Entries, + IReadOnlyDictionary? Labels, + bool Overwrite = true); + +/// +/// Describes the authrefs created for a staged credential bundle. +/// +public sealed record UpsertSecretBundleResponse( + Guid TargetIntegrationId, + string BundleId, + string LogicalPath, + IReadOnlyDictionary AuthRefs, + IntegrationProvider Provider, + string Endpoint); diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/SecretAuthorityServiceTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/SecretAuthorityServiceTests.cs new file mode 100644 index 000000000..25e9ada26 --- /dev/null +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/SecretAuthorityServiceTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using StellaOps.Integrations.Persistence; +using StellaOps.Integrations.WebService; +using Xunit; + +namespace StellaOps.Integrations.Tests; + +public sealed class SecretAuthorityServiceTests +{ + private readonly Mock _repositoryMock = new(); + private readonly Mock _authRefResolverMock = new(); + private readonly Mock _httpClientFactoryMock = new(); + + [Fact] + public async Task ListTargetsAsync_ScopesQueryToTenantAndMapsStatuses() + { + var vault = CreateSecretManager("Vault", IntegrationProvider.Vault, status: IntegrationStatus.Active, health: HealthStatus.Healthy); + var consul = CreateSecretManager("Consul", IntegrationProvider.Consul, status: IntegrationStatus.Pending, health: HealthStatus.Unknown); + + _repositoryMock + .Setup(repository => repository.GetAllAsync( + It.Is(query => + query.Type == IntegrationType.SecretsManager && + query.TenantId == "tenant-a" && + query.SortBy == "name"), + It.IsAny())) + .ReturnsAsync([vault, consul]); + + var service = CreateService(); + + var result = await service.ListTargetsAsync("tenant-a", CancellationToken.None); + + result.Items.Select(item => item.Name).Should().Equal("Consul", "Vault"); + result.Items.Should().ContainSingle(item => item.Provider == IntegrationProvider.Vault && item.SupportsWrite && item.Status == "healthy"); + result.Items.Should().ContainSingle(item => item.Provider == IntegrationProvider.Consul && !item.SupportsWrite && item.Status == "pending"); + } + + [Fact] + public async Task UpsertBundleAsync_WithVaultTarget_WritesSecretAndReturnsAuthRefsWithoutLeakingValues() + { + var target = CreateSecretManager("Local Vault", IntegrationProvider.Vault); + _repositoryMock + .Setup(repository => repository.GetByIdAsync(target.Id, It.IsAny())) + .ReturnsAsync(target); + _authRefResolverMock + .Setup(resolver => resolver.ResolveAsync("authref://vault/system#token", It.IsAny())) + .ReturnsAsync("vault-token"); + + var recordedRequests = new List(); + var handler = new RecordingHttpMessageHandler(async request => + { + var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(); + recordedRequests.Add(new RecordedVaultRequest( + request.Method.Method, + request.RequestUri?.AbsolutePath ?? string.Empty, + request.Headers.TryGetValues("X-Vault-Token", out var values) ? values.Single() : null, + body)); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + }); + + _httpClientFactoryMock + .Setup(factory => factory.CreateClient(It.IsAny())) + .Returns(new HttpClient(handler) + { + BaseAddress = new Uri("http://vault.stella-ops.local:8200/") + }); + + var service = CreateService(); + var request = new UpsertSecretBundleRequest( + target.Id, + null, + [ + new SecretBundleEntryRequest("access-token", "glpat-super-secret"), + new SecretBundleEntryRequest("registry-basic", "root:glpat-super-secret") + ], + new Dictionary(StringComparer.Ordinal) + { + ["source"] = "ui" + }, + Overwrite: true); + + var result = await service.UpsertBundleAsync("gitlab", request, "tenant-a", CancellationToken.None); + + result.LogicalPath.Should().Be("gitlab"); + result.AuthRefs.Should().BeEquivalentTo(new Dictionary(StringComparer.Ordinal) + { + ["access-token"] = "authref://vault/gitlab#access-token", + ["registry-basic"] = "authref://vault/gitlab#registry-basic" + }); + JsonSerializer.Serialize(result).Should().NotContain("glpat-super-secret"); + + recordedRequests.Should().HaveCount(2); + recordedRequests[0].Path.Should().Be("/v1/secret/data/gitlab"); + recordedRequests[0].Token.Should().Be("vault-token"); + recordedRequests[0].Body.Should().Contain("access-token"); + recordedRequests[0].Body.Should().Contain("glpat-super-secret"); + recordedRequests[1].Path.Should().Be("/v1/secret/metadata/gitlab"); + recordedRequests[1].Body.Should().Contain("source"); + } + + [Fact] + public async Task UpsertBundleAsync_WithOverwriteDisabledAndExistingSecret_ReturnsConflict() + { + var target = CreateSecretManager("Local Vault", IntegrationProvider.Vault); + _repositoryMock + .Setup(repository => repository.GetByIdAsync(target.Id, It.IsAny())) + .ReturnsAsync(target); + _authRefResolverMock + .Setup(resolver => resolver.ResolveAsync("authref://vault/system#token", It.IsAny())) + .ReturnsAsync("vault-token"); + + var handler = new RecordingHttpMessageHandler(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + })); + + _httpClientFactoryMock + .Setup(factory => factory.CreateClient(It.IsAny())) + .Returns(new HttpClient(handler) + { + BaseAddress = new Uri("http://vault.stella-ops.local:8200/") + }); + + var service = CreateService(); + var request = new UpsertSecretBundleRequest( + target.Id, + null, + [new SecretBundleEntryRequest("access-token", "glpat-super-secret")], + Labels: null, + Overwrite: false); + + var exception = await Assert.ThrowsAsync(() => + service.UpsertBundleAsync("gitlab", request, "tenant-a", CancellationToken.None)); + + exception.StatusCode.Should().Be(StatusCodes.Status409Conflict); + exception.ErrorCode.Should().Be("bundle_exists"); + } + + [Fact] + public async Task UpsertBundleAsync_WithUnsupportedProvider_ReturnsNotImplemented() + { + var target = CreateSecretManager("Local Consul", IntegrationProvider.Consul); + _repositoryMock + .Setup(repository => repository.GetByIdAsync(target.Id, It.IsAny())) + .ReturnsAsync(target); + + var service = CreateService(); + var request = new UpsertSecretBundleRequest( + target.Id, + null, + [new SecretBundleEntryRequest("token", "value")], + Labels: null, + Overwrite: true); + + var exception = await Assert.ThrowsAsync(() => + service.UpsertBundleAsync("consul", request, "tenant-a", CancellationToken.None)); + + exception.StatusCode.Should().Be(StatusCodes.Status501NotImplemented); + exception.ErrorCode.Should().Be("provider_not_supported"); + } + + [Fact] + public async Task UpsertBundleAsync_WithTenantMismatch_ReturnsNotFound() + { + var target = CreateSecretManager("Local Vault", IntegrationProvider.Vault, tenantId: "tenant-b"); + _repositoryMock + .Setup(repository => repository.GetByIdAsync(target.Id, It.IsAny())) + .ReturnsAsync(target); + + var service = CreateService(); + var request = new UpsertSecretBundleRequest( + target.Id, + null, + [new SecretBundleEntryRequest("token", "value")], + Labels: null, + Overwrite: true); + + var exception = await Assert.ThrowsAsync(() => + service.UpsertBundleAsync("gitlab", request, "tenant-a", CancellationToken.None)); + + exception.StatusCode.Should().Be(StatusCodes.Status404NotFound); + exception.ErrorCode.Should().Be("target_not_found"); + } + + private SecretAuthorityService CreateService() + { + return new SecretAuthorityService( + _repositoryMock.Object, + _authRefResolverMock.Object, + _httpClientFactoryMock.Object, + NullLogger.Instance); + } + + private static Integration CreateSecretManager( + string name, + IntegrationProvider provider, + string tenantId = "tenant-a", + IntegrationStatus status = IntegrationStatus.Pending, + HealthStatus health = HealthStatus.Unknown) + { + var now = DateTimeOffset.Parse("2026-04-14T12:00:00Z"); + return new Integration + { + Id = Guid.NewGuid(), + Name = name, + Description = $"{name} integration", + Type = IntegrationType.SecretsManager, + Provider = provider, + Status = status, + Endpoint = $"http://{name.ToLowerInvariant().Replace(' ', '-')}.stella-ops.local", + AuthRefUri = provider == IntegrationProvider.Vault ? "authref://vault/system#token" : null, + OrganizationId = null, + ConfigJson = null, + LastHealthStatus = health, + LastHealthCheckAt = now, + CreatedAt = now, + UpdatedAt = now, + CreatedBy = "test-user", + UpdatedBy = "test-user", + TenantId = tenantId, + Tags = ["test"], + IsDeleted = false + }; + } + + private sealed record RecordedVaultRequest(string Method, string Path, string? Token, string? Body); + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public RecordingHttpMessageHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _handler(request); + } + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs index 2b5f88e54..e1cf2c82b 100644 --- a/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs @@ -288,6 +288,7 @@ public static class AuditActions public const string Test = "test"; public const string Discover = "discover"; public const string RunCodeGuard = "run_code_guard"; + public const string UpsertSecretBundle = "upsert_secret_bundle"; } /// Actions for the Platform module.