Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Surface.Secrets.Tests")]
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public interface ISurfaceSecretProvider
|
||||
{
|
||||
ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class CompositeSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly IReadOnlyList<ISurfaceSecretProvider> _providers;
|
||||
|
||||
public CompositeSurfaceSecretProvider(IEnumerable<ISurfaceSecretProvider> providers)
|
||||
{
|
||||
_providers = providers?.ToArray() ?? throw new ArgumentNullException(nameof(providers));
|
||||
if (_providers.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one provider must be supplied.", nameof(providers));
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await provider.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
// try next provider
|
||||
}
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class FileSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public FileSurfaceSecretProvider(string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new ArgumentException("File secret provider root cannot be null or whitespace.", nameof(root));
|
||||
}
|
||||
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var path = ResolvePath(request);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var descriptor = await JsonSerializer.DeserializeAsync<FileSecretDescriptor>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, descriptor.Metadata);
|
||||
}
|
||||
|
||||
private string ResolvePath(SurfaceSecretRequest request)
|
||||
{
|
||||
var name = request.Name ?? "default";
|
||||
return Path.Combine(_root, request.Tenant, request.Component, request.SecretType, name + ".json");
|
||||
}
|
||||
|
||||
private sealed class FileSecretDescriptor
|
||||
{
|
||||
public string? Payload { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
public sealed class InMemorySurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SurfaceSecretHandle> _secrets = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Add(SurfaceSecretRequest request, SurfaceSecretHandle handle)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (handle is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(handle));
|
||||
}
|
||||
|
||||
_secrets[request.CacheKey] = handle;
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (_secrets.TryGetValue(request.CacheKey, out var handle))
|
||||
{
|
||||
return ValueTask.FromResult(handle);
|
||||
}
|
||||
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class InlineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly SurfaceSecretsConfiguration _configuration;
|
||||
|
||||
public InlineSurfaceSecretProvider(SurfaceSecretsConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_configuration.AllowInline)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var envKey = BuildEnvironmentKey(request);
|
||||
var value = Environment.GetEnvironmentVariable(envKey);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(value);
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["source"] = "inline-env",
|
||||
["key"] = envKey
|
||||
};
|
||||
|
||||
return ValueTask.FromResult(SurfaceSecretHandle.FromBytes(bytes, metadata));
|
||||
}
|
||||
|
||||
private static string BuildEnvironmentKey(SurfaceSecretRequest request)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(request.Name) ? "DEFAULT" : request.Name.ToUpperInvariant();
|
||||
return $"SURFACE_SECRET_{request.Tenant.ToUpperInvariant()}_{request.Component.ToUpperInvariant()}_{request.SecretType.ToUpperInvariant()}_{name}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
internal sealed class KubernetesSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly SurfaceSecretsConfiguration _configuration;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public KubernetesSurfaceSecretProvider(SurfaceSecretsConfiguration configuration, ILogger logger)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuration.Root))
|
||||
{
|
||||
throw new ArgumentException("Kubernetes secret provider requires a root directory where secrets are mounted.", nameof(configuration));
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var directory = Path.Combine(_configuration.Root!, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Kubernetes secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var name = request.Name ?? "default";
|
||||
var payloadPath = Path.Combine(directory, name);
|
||||
if (!File.Exists(payloadPath))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(payloadPath, cancellationToken).ConfigureAwait(false);
|
||||
return SurfaceSecretHandle.FromBytes(bytes, new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "kubernetes",
|
||||
["path"] = payloadPath
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSurfaceSecrets(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceSecretsOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<SurfaceSecretsOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<ISurfaceSecretProvider>(sp =>
|
||||
{
|
||||
var env = sp.GetRequiredService<ISurfaceEnvironment>();
|
||||
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
|
||||
return CreateProvider(env.Settings.Secrets, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static ISurfaceSecretProvider CreateProvider(SurfaceSecretsConfiguration configuration, ILogger logger)
|
||||
{
|
||||
var providers = new List<ISurfaceSecretProvider>();
|
||||
|
||||
switch (configuration.Provider.ToLowerInvariant())
|
||||
{
|
||||
case "kubernetes":
|
||||
providers.Add(new KubernetesSurfaceSecretProvider(configuration, logger));
|
||||
break;
|
||||
case "file":
|
||||
providers.Add(new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider.")));
|
||||
break;
|
||||
case "inline":
|
||||
providers.Add(new InlineSurfaceSecretProvider(configuration));
|
||||
break;
|
||||
default:
|
||||
logger.LogWarning("Unknown surface secret provider '{Provider}'. Falling back to inline provider.", configuration.Provider);
|
||||
providers.Add(new InlineSurfaceSecretProvider(configuration));
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.FallbackProvider))
|
||||
{
|
||||
providers.Add(new InlineSurfaceSecretProvider(configuration with { Provider = configuration.FallbackProvider }));
|
||||
}
|
||||
|
||||
return providers.Count == 1 ? providers[0] : new CompositeSurfaceSecretProvider(providers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public sealed class SurfaceSecretHandle : IDisposable
|
||||
{
|
||||
private readonly byte[]? _buffer;
|
||||
private readonly int _length;
|
||||
private readonly X509Certificate2Collection? _certificates;
|
||||
private bool _disposed;
|
||||
|
||||
private SurfaceSecretHandle(byte[]? buffer, int length, X509Certificate2Collection? certificates, IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
_buffer = buffer;
|
||||
_length = length;
|
||||
_certificates = certificates;
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
public ReadOnlyMemory<byte> AsBytes()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _buffer is null ? ReadOnlyMemory<byte>.Empty : new ReadOnlyMemory<byte>(_buffer, 0, _length);
|
||||
}
|
||||
|
||||
public X509Certificate2Collection? AsCertificateCollection()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _certificates;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_buffer is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(_buffer.AsSpan(0, _length));
|
||||
ArrayPool<byte>.Shared.Return(_buffer);
|
||||
}
|
||||
|
||||
if (_certificates is not null)
|
||||
{
|
||||
foreach (var certificate in _certificates)
|
||||
{
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SurfaceSecretHandle));
|
||||
}
|
||||
}
|
||||
|
||||
public static SurfaceSecretHandle FromBytes(ReadOnlySpan<byte> bytes, IDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(bytes.Length);
|
||||
bytes.CopyTo(buffer);
|
||||
var readOnlyMetadata = metadata is null
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
return new SurfaceSecretHandle(buffer, bytes.Length, null, readOnlyMetadata);
|
||||
}
|
||||
|
||||
public static SurfaceSecretHandle Empty { get; } = new SurfaceSecretHandle(null, 0, null, new Dictionary<string, string>());
|
||||
|
||||
public static SurfaceSecretHandle FromCertificates(X509Certificate2Collection certificates, IDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var readOnlyMetadata = metadata is null
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
return new SurfaceSecretHandle(null, 0, certificates, readOnlyMetadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public sealed class SurfaceSecretNotFoundException : Exception
|
||||
{
|
||||
public SurfaceSecretNotFoundException(SurfaceSecretRequest request)
|
||||
: base($"Surface secret not found for tenant '{request.Tenant}', component '{request.Component}', type '{request.SecretType}'.")
|
||||
{
|
||||
Request = request;
|
||||
}
|
||||
|
||||
public SurfaceSecretRequest Request { get; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
public sealed record SurfaceSecretRequest(
|
||||
string Tenant,
|
||||
string Component,
|
||||
string SecretType,
|
||||
string? Name = null)
|
||||
{
|
||||
public string CacheKey => string.Join(':', Tenant, Component, SecretType, Name ?? "default");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the surface secrets subsystem.
|
||||
/// </summary>
|
||||
public sealed class SurfaceSecretsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the logical component name requesting secrets.
|
||||
/// </summary>
|
||||
public string ComponentName { get; set; } = "Scanner.Worker";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of secret types that should be eagerly validated at startup.
|
||||
/// </summary>
|
||||
public ISet<string> RequiredSecretTypes { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-SECRETS-01 | TODO | Scanner Guild, Security Guild | ARCH-SURFACE-EPIC | Produce `docs/modules/scanner/design/surface-secrets.md` defining secret reference schema, storage backends, scopes, and rotation. | Spec approved by Security + Authority guilds; threat model ticket logged. |
|
||||
| SURFACE-SECRETS-02 | TODO | Scanner Guild | SURFACE-SECRETS-01 | Implement `StellaOps.Scanner.Surface.Secrets` core provider interfaces, secret models, and in-memory test backend. | Library builds; tests pass; XML docs cover public API. |
|
||||
| SURFACE-SECRETS-01 | DOING (2025-11-02) | Scanner Guild, Security Guild | ARCH-SURFACE-EPIC | Produce `docs/modules/scanner/design/surface-secrets.md` defining secret reference schema, storage backends, scopes, and rotation. | Spec approved by Security + Authority guilds; threat model ticket logged. |
|
||||
| SURFACE-SECRETS-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-SECRETS-01 | Implement `StellaOps.Scanner.Surface.Secrets` core provider interfaces, secret models, and in-memory test backend. | Library builds; tests pass; XML docs cover public API. |
|
||||
| SURFACE-SECRETS-03 | TODO | Scanner Guild | SURFACE-SECRETS-02 | Add Kubernetes/File/Offline backends with deterministic caching and audit hooks. | Backends integrated; integration tests simulate rotation + offline bundles. |
|
||||
| SURFACE-SECRETS-04 | TODO | Scanner Guild | SURFACE-SECRETS-02 | Integrate Surface.Secrets into Scanner Worker/WebService/BuildX for registry + CAS creds. | Scanner components consume library; legacy secret code removed; smoke tests updated. |
|
||||
| SURFACE-SECRETS-05 | TODO | Zastava Guild | SURFACE-SECRETS-02 | Invoke Surface.Secrets from Zastava Observer/Webhook for CAS & attestation secrets. | Zastava uses shared provider; admission + observer tests cover secret errors. |
|
||||
|
||||
Reference in New Issue
Block a user