feat(integrations): secret authority service for UI-driven secret staging

Add SecretAuthorityService + endpoints so the setup wizard and
integrations hub can stage secret bundles and bind authref URIs
directly from the UI, instead of requiring out-of-band Vault seeding.
Wire the new service behind IntegrationPolicies, expose
SecretAuthorityDtos on the contracts library, and register an
UpsertSecretBundle audit action for the emission library.

Closes BOOTSTRAP-006 from SPRINT_20260413_004.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-14 07:55:49 +03:00
parent cadfe10fcc
commit 78afc39d2d
7 changed files with 821 additions and 0 deletions

View File

@@ -118,6 +118,7 @@ builder.Services.AddScoped<IAuthRefResolver, VaultAuthRefResolver>();
// Core service
builder.Services.AddScoped<IntegrationService>();
builder.Services.AddScoped<SecretAuthorityService>();
builder.Services.AddSingleton<IAiCodeGuardPipelineConfigLoader, AiCodeGuardPipelineConfigLoader>();
builder.Services.AddScoped<IAiCodeGuardRunService, AiCodeGuardRunService>();
@@ -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 }))

View File

@@ -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<string, object?>
{
["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);
}
}

View File

@@ -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<SecretAuthorityService> _logger;
public SecretAuthorityService(
IIntegrationRepository repository,
IAuthRefResolver authRefResolver,
IHttpClientFactory httpClientFactory,
ILogger<SecretAuthorityService> logger)
{
_repository = repository;
_authRefResolver = authRefResolver;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<SecretAuthorityTargetsResponse> 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<UpsertSecretBundleResponse> 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<UpsertSecretBundleResponse> 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<SecretBundleEntryRequest>? 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<string>(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<string> 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<bool> 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<string, string> 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<string, string> 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<SecretAuthorityException> 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);
}

View File

@@ -16,4 +16,10 @@ internal static class IntegrationPolicies
/// <summary>Policy for executing integration operations (test connections, AI Code Guard runs). Requires integration:operate scope.</summary>
public const string Operate = "Integration.Operate";
/// <summary>Policy for reading secret-authority targets. Currently mapped to integration:read.</summary>
public const string SecretAuthorityRead = "Integration.SecretAuthority.Read";
/// <summary>Policy for staging secret-authority bundles. Currently mapped to integration:write.</summary>
public const string SecretAuthorityWrite = "Integration.SecretAuthority.Write";
}

View File

@@ -0,0 +1,49 @@
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Contracts;
/// <summary>
/// Describes a secret-authority target that can accept staged credential bundles.
/// </summary>
public sealed record SecretAuthorityTargetResponse(
Guid IntegrationId,
string Name,
IntegrationProvider Provider,
string Endpoint,
string Status,
string LogicalPathHint,
bool SupportsWrite);
/// <summary>
/// Lists secret-authority targets visible to the current tenant.
/// </summary>
public sealed record SecretAuthorityTargetsResponse(
IReadOnlyList<SecretAuthorityTargetResponse> Items);
/// <summary>
/// Single secret entry staged into a bundle.
/// </summary>
public sealed record SecretBundleEntryRequest(
string Key,
string Value);
/// <summary>
/// Writes or updates a staged credential bundle in the selected secret authority.
/// </summary>
public sealed record UpsertSecretBundleRequest(
Guid TargetIntegrationId,
string? LogicalPath,
IReadOnlyList<SecretBundleEntryRequest> Entries,
IReadOnlyDictionary<string, string>? Labels,
bool Overwrite = true);
/// <summary>
/// Describes the authrefs created for a staged credential bundle.
/// </summary>
public sealed record UpsertSecretBundleResponse(
Guid TargetIntegrationId,
string BundleId,
string LogicalPath,
IReadOnlyDictionary<string, string> AuthRefs,
IntegrationProvider Provider,
string Endpoint);

View File

@@ -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<IIntegrationRepository> _repositoryMock = new();
private readonly Mock<IAuthRefResolver> _authRefResolverMock = new();
private readonly Mock<IHttpClientFactory> _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<IntegrationQuery>(query =>
query.Type == IntegrationType.SecretsManager &&
query.TenantId == "tenant-a" &&
query.SortBy == "name"),
It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(target);
_authRefResolverMock
.Setup(resolver => resolver.ResolveAsync("authref://vault/system#token", It.IsAny<CancellationToken>()))
.ReturnsAsync("vault-token");
var recordedRequests = new List<RecordedVaultRequest>();
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<string>()))
.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<string, string>(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<string, string>(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<CancellationToken>()))
.ReturnsAsync(target);
_authRefResolverMock
.Setup(resolver => resolver.ResolveAsync("authref://vault/system#token", It.IsAny<CancellationToken>()))
.ReturnsAsync("vault-token");
var handler = new RecordingHttpMessageHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
}));
_httpClientFactoryMock
.Setup(factory => factory.CreateClient(It.IsAny<string>()))
.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<SecretAuthorityException>(() =>
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<CancellationToken>()))
.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<SecretAuthorityException>(() =>
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<CancellationToken>()))
.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<SecretAuthorityException>(() =>
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<SecretAuthorityService>.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<HttpRequestMessage, Task<HttpResponseMessage>> _handler;
public RecordingHttpMessageHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return _handler(request);
}
}
}

View File

@@ -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";
}
/// <summary>Actions for the Platform module.</summary>