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:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Surface.Secrets.Tests")]

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
namespace StellaOps.Scanner.Surface.Secrets;
public interface ISurfaceSecretProvider
{
ValueTask<SurfaceSecretHandle> GetAsync(
SurfaceSecretRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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}";
}
}

View File

@@ -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
});
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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");
}

View File

@@ -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);
}

View File

@@ -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. |