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:
@@ -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 }))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user