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.Env.Tests")]

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.Surface.Env;
/// <summary>
/// Provides resolved surface environment settings for a component.
/// </summary>
public interface ISurfaceEnvironment
{
/// <summary>
/// Gets the resolved settings for the current component.
/// </summary>
SurfaceEnvironmentSettings Settings { get; }
/// <summary>
/// Gets the raw environment variables and configuration values that were used while building the settings.
/// </summary>
IReadOnlyDictionary<string, string> RawVariables { get; }
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Scanner.Surface.Env;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddSurfaceEnvironment(
this IServiceCollection services,
Action<SurfaceEnvironmentOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddSingleton<ISurfaceEnvironment>(sp => SurfaceEnvironmentFactory.Create(sp, configure));
return services;
}
}

View File

@@ -0,0 +1,20 @@
<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>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Surface.Env;
internal sealed class SurfaceEnvironment : ISurfaceEnvironment
{
public SurfaceEnvironment(SurfaceEnvironmentSettings settings, IReadOnlyDictionary<string, string> raw)
{
Settings = settings ?? throw new ArgumentNullException(nameof(settings));
RawVariables = raw ?? throw new ArgumentNullException(nameof(raw));
}
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; }
}
internal static class SurfaceEnvironmentFactory
{
public static ISurfaceEnvironment Create(IServiceProvider services, Action<SurfaceEnvironmentOptions>? configure = null)
{
var options = new SurfaceEnvironmentOptions();
configure?.Invoke(options);
if (options.Prefixes.Count == 0)
{
options.AddPrefix("SCANNER");
}
var configuration = services.GetRequiredService<IConfiguration>();
var logger = services.GetRequiredService<ILogger<SurfaceEnvironmentBuilder>>();
var builder = new SurfaceEnvironmentBuilder(services, configuration, logger, options);
var settings = builder.Build();
var raw = builder.GetRawVariables();
return new SurfaceEnvironment(settings, raw);
}
}

View File

@@ -0,0 +1,295 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Surface.Env;
/// <summary>
/// Resolves <see cref="SurfaceEnvironmentSettings"/> instances from configuration sources.
/// </summary>
public sealed class SurfaceEnvironmentBuilder
{
private readonly IServiceProvider _services;
private readonly IConfiguration _configuration;
private readonly ILogger<SurfaceEnvironmentBuilder> _logger;
private readonly SurfaceEnvironmentOptions _options;
private readonly Dictionary<string, string> _raw = new(StringComparer.OrdinalIgnoreCase);
public SurfaceEnvironmentBuilder(
IServiceProvider services,
IConfiguration configuration,
ILogger<SurfaceEnvironmentBuilder> logger,
SurfaceEnvironmentOptions options)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
if (_options.Prefixes.Count == 0)
{
_options.AddPrefix("SCANNER");
}
}
public SurfaceEnvironmentSettings Build()
{
var endpoint = ResolveUri("SURFACE_FS_ENDPOINT", required: _options.RequireSurfaceEndpoint);
var bucket = ResolveString("SURFACE_FS_BUCKET", "surface-cache", required: endpoint is not null);
var region = ResolveOptionalString("SURFACE_FS_REGION");
var cacheRoot = ResolveDirectory("SURFACE_CACHE_ROOT", new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops", "surface")));
var cacheQuota = ResolveInt("SURFACE_CACHE_QUOTA_MB", 4096, min: 64, max: 262144);
var prefetch = ResolveBool("SURFACE_PREFETCH_ENABLED", defaultValue: false);
var featureFlags = ResolveFeatureFlags();
var secrets = ResolveSecretsConfiguration();
var tls = ResolveTlsConfiguration();
var tenant = ResolveTenant() ?? "default";
var settings = new SurfaceEnvironmentSettings(
endpoint ?? new Uri("https://surface.invalid"),
bucket,
region,
cacheRoot,
cacheQuota,
prefetch,
featureFlags,
secrets,
tenant,
tls);
return settings with { CreatedAtUtc = DateTimeOffset.UtcNow };
}
public IReadOnlyDictionary<string, string> GetRawVariables()
=> new Dictionary<string, string>(_raw, StringComparer.OrdinalIgnoreCase);
private Uri? ResolveUri(string suffix, bool required)
{
var value = ResolveString(suffix, required: required);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
throw new SurfaceEnvironmentException($"Value '{value}' for {suffix} is not a valid absolute URI.", suffix);
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Surface environment endpoint {Endpoint} is not HTTPS.", uri);
}
return uri;
}
private string ResolveString(string suffix, string? defaultValue = null, bool required = false)
{
var value = ResolveOptionalString(suffix);
if (!string.IsNullOrWhiteSpace(value))
{
return value!;
}
if (required && defaultValue is null)
{
throw new SurfaceEnvironmentException($"Required surface environment variable {FormatNames(suffix)} was not provided.", suffix);
}
return defaultValue ?? string.Empty;
}
private string? ResolveOptionalString(string suffix)
{
foreach (var name in EnumerateNames(suffix))
{
var value = Environment.GetEnvironmentVariable(name);
if (!string.IsNullOrWhiteSpace(value))
{
_raw[name] = value!;
return value;
}
}
var configKey = BuildConfigurationKey(suffix);
var configured = _configuration[configKey];
if (!string.IsNullOrWhiteSpace(configured))
{
_raw[configKey] = configured!;
return configured;
}
return null;
}
private DirectoryInfo ResolveDirectory(string suffix, DirectoryInfo fallback)
{
var path = ResolveOptionalString(suffix) ?? fallback.FullName;
var directory = new DirectoryInfo(path);
if (!directory.Exists)
{
directory.Create();
}
return directory;
}
private int ResolveInt(string suffix, int defaultValue, int min, int max)
{
var value = ResolveOptionalString(suffix);
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
throw new SurfaceEnvironmentException($"Value '{value}' for {suffix} is not a valid integer.", suffix);
}
if (parsed < min || parsed > max)
{
throw new SurfaceEnvironmentException($"Value '{parsed}' for {suffix} must be between {min} and {max}.", suffix);
}
return parsed;
}
private bool ResolveBool(string suffix, bool defaultValue)
{
var value = ResolveOptionalString(suffix);
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
if (!bool.TryParse(value, out var parsed))
{
throw new SurfaceEnvironmentException($"Value '{value}' for {suffix} is not a valid boolean.", suffix);
}
return parsed;
}
private IReadOnlyCollection<string> ResolveFeatureFlags()
{
var rawFlags = ResolveOptionalString("SURFACE_FEATURES");
if (string.IsNullOrWhiteSpace(rawFlags))
{
return Array.Empty<string>();
}
var flags = rawFlags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(flag => flag.ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var flag in flags)
{
if (_options.KnownFeatureFlags.Count > 0 && !_options.KnownFeatureFlags.Contains(flag))
{
_logger.LogWarning("Unknown surface feature flag '{Flag}' detected for component {Component}.", flag, _options.ComponentName);
}
else
{
_logger.LogDebug("Surface environment feature flag detected: {Flag}.", flag);
}
}
return flags;
}
private SurfaceSecretsConfiguration ResolveSecretsConfiguration()
{
var provider = ResolveString("SURFACE_SECRETS_PROVIDER", "kubernetes");
var root = ResolveOptionalString("SURFACE_SECRETS_ROOT");
var ns = ResolveOptionalString("SURFACE_SECRETS_NAMESPACE");
var fallback = ResolveOptionalString("SURFACE_SECRETS_FALLBACK_PROVIDER");
var allowInline = ResolveBool("SURFACE_SECRETS_ALLOW_INLINE", defaultValue: false);
var tenant = ResolveOptionalString("SURFACE_SECRETS_TENANT") ?? ResolveTenant() ?? "default";
return new SurfaceSecretsConfiguration(provider, tenant, root, ns, fallback, allowInline);
}
private SurfaceTlsConfiguration ResolveTlsConfiguration()
{
var certPath = ResolveOptionalString("SURFACE_TLS_CERT_PATH");
var keyPath = ResolveOptionalString("SURFACE_TLS_KEY_PATH");
X509Certificate2Collection? certificates = null;
if (!string.IsNullOrWhiteSpace(certPath))
{
try
{
if (!File.Exists(certPath))
{
throw new FileNotFoundException("TLS certificate path not found.", certPath);
}
var certificate = X509CertificateLoader.LoadCertificateFromFile(certPath);
certificates = new X509Certificate2Collection { certificate };
}
catch (Exception ex)
{
throw new SurfaceEnvironmentException($"Failed to load TLS certificate from '{certPath}': {ex.Message}", "SURFACE_TLS_CERT_PATH", ex);
}
}
return new SurfaceTlsConfiguration(certPath, keyPath, certificates);
}
private string? ResolveTenant()
{
var tenant = ResolveOptionalString("SURFACE_TENANT");
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant;
}
if (_options.TenantResolver is not null)
{
try
{
tenant = _options.TenantResolver(_services);
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Tenant resolver for component {Component} threw an exception.", _options.ComponentName);
}
}
return null;
}
private IEnumerable<string> EnumerateNames(string suffix)
{
foreach (var prefix in _options.Prefixes)
{
yield return $"{prefix}_{suffix}";
}
yield return suffix;
}
private string BuildConfigurationKey(string suffix)
{
var withoutPrefix = suffix.StartsWith("SURFACE_", StringComparison.OrdinalIgnoreCase)
? suffix[8..]
: suffix;
return $"Surface:{withoutPrefix.Replace('_', ':')}";
}
private string FormatNames(string suffix)
=> string.Join(", ", EnumerateNames(suffix));
}

View File

@@ -0,0 +1,20 @@
using System;
namespace StellaOps.Scanner.Surface.Env;
public sealed class SurfaceEnvironmentException : Exception
{
public SurfaceEnvironmentException(string message, string variable)
: base(message)
{
Variable = variable;
}
public SurfaceEnvironmentException(string message, string variable, Exception innerException)
: base(message, innerException)
{
Variable = variable;
}
public string Variable { get; }
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.Surface.Env;
/// <summary>
/// Options controlling how the surface environment is resolved.
/// </summary>
public sealed class SurfaceEnvironmentOptions
{
private readonly List<string> _prefixes = new();
/// <summary>
/// Gets or sets the logical component name (e.g. "Scanner.Worker", "Zastava.Observer").
/// </summary>
public string ComponentName { get; set; } = "Scanner.Worker";
/// <summary>
/// Gets the ordered list of environment variable prefixes that will be probed when resolving configuration values.
/// The prefixes are evaluated in order; the first match wins.
/// </summary>
public IReadOnlyList<string> Prefixes => _prefixes;
/// <summary>
/// Adds a prefix to the ordered prefix list.
/// </summary>
public void AddPrefix(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
throw new ArgumentException("Prefix cannot be null or whitespace.", nameof(prefix));
}
if (!_prefixes.Contains(prefix, StringComparer.OrdinalIgnoreCase))
{
_prefixes.Add(prefix);
}
}
/// <summary>
/// When set to <c>true</c>, a missing Surface FS endpoint raises an exception.
/// </summary>
public bool RequireSurfaceEndpoint { get; set; } = true;
/// <summary>
/// Optional delegate used to resolve the tenant when not explicitly provided via environment variables.
/// </summary>
public Func<IServiceProvider, string?>? TenantResolver { get; set; }
/// <summary>
/// Gets or sets the set of recognised feature flags. Unknown flags produce validation warnings.
/// </summary>
public ISet<string> KnownFeatureFlags { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Scanner.Surface.Env;
/// <summary>
/// Snapshot of the resolved surface environment configuration for a component.
/// </summary>
public sealed record SurfaceEnvironmentSettings(
Uri SurfaceFsEndpoint,
string SurfaceFsBucket,
string? SurfaceFsRegion,
DirectoryInfo CacheRoot,
int CacheQuotaMegabytes,
bool PrefetchEnabled,
IReadOnlyCollection<string> FeatureFlags,
SurfaceSecretsConfiguration Secrets,
string Tenant,
SurfaceTlsConfiguration Tls)
{
/// <summary>
/// Gets the timestamp (UTC) when the configuration snapshot was created.
/// </summary>
public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Scanner.Surface.Env;
/// <summary>
/// Represents secret provider configuration resolved for the current component.
/// </summary>
public sealed record SurfaceSecretsConfiguration(
string Provider,
string Tenant,
string? Root,
string? Namespace,
string? FallbackProvider,
bool AllowInline)
{
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackProvider);
}

View File

@@ -0,0 +1,14 @@
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Scanner.Surface.Env;
/// <summary>
/// TLS configuration associated with the surface endpoints.
/// </summary>
public sealed record SurfaceTlsConfiguration(
string? CertificatePath,
string? PrivateKeyPath,
X509Certificate2Collection? ClientCertificates)
{
public bool HasClientCertificates => ClientCertificates is { Count: > 0 };
}

View File

@@ -2,8 +2,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SURFACE-ENV-01 | TODO | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Draft `docs/modules/scanner/design/surface-env.md` enumerating environment variables, defaults, and air-gap behaviour. | Spec merged; env matrix reviewed by Ops + Security. |
| SURFACE-ENV-02 | TODO | Scanner Guild | SURFACE-ENV-01 | Implement strongly-typed env accessors in `StellaOps.Scanner.Surface.Env` with validation and deterministic logging. | Library published; unit tests cover parsing, fallbacks, and error paths. |
| SURFACE-ENV-01 | DOING (2025-11-01) | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Draft `docs/modules/scanner/design/surface-env.md` enumerating environment variables, defaults, and air-gap behaviour. | Spec merged; env matrix reviewed by Ops + Security. |
| SURFACE-ENV-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-ENV-01 | Implement strongly-typed env accessors in `StellaOps.Scanner.Surface.Env` with validation and deterministic logging. | Library published; unit tests cover parsing, fallbacks, and error paths. |
| SURFACE-ENV-03 | TODO | Scanner Guild | SURFACE-ENV-02 | Adopt env helper across Scanner Worker/WebService/BuildX plug-ins. | Services use helper; manifests updated; smoke tests green. |
| SURFACE-ENV-04 | TODO | Zastava Guild | SURFACE-ENV-02 | Wire env helper into Zastava Observer/Webhook containers. | Zastava builds reference env helper; admission tests validated. |
| SURFACE-ENV-05 | TODO | Ops Guild | SURFACE-ENV-03..04 | Update Helm/Compose/offline kit templates with new env knobs and documentation. | Templates merged; docs include configuration table; air-gap scripts updated. |