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,163 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <summary>
/// Advisory AI configuration (feature flags, remote inference policies).
/// </summary>
public sealed class AuthorityAdvisoryAiOptions
{
public AdvisoryAiRemoteInferenceOptions RemoteInference { get; } = new();
internal void Normalize()
{
RemoteInference.Normalize();
}
internal void Validate()
{
RemoteInference.Validate();
}
}
public sealed class AdvisoryAiRemoteInferenceOptions
{
private readonly List<string> allowedProfiles = new();
/// <summary>
/// Controls whether remote inference endpoints (cloud or third-party) are permitted.
/// Disabled by default for sovereign/offline installs.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Requires tenants to explicitly opt-in before remote inference may be invoked on their behalf.
/// </summary>
public bool RequireTenantConsent { get; set; } = true;
/// <summary>
/// Remote inference profiles permitted when <see cref="Enabled"/> is true (e.g. cloud-openai, vendor-xyz).
/// </summary>
public IList<string> AllowedProfiles => allowedProfiles;
internal void Normalize()
{
if (allowedProfiles.Count == 0)
{
return;
}
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = allowedProfiles.Count - 1; index >= 0; index--)
{
var entry = allowedProfiles[index];
if (string.IsNullOrWhiteSpace(entry))
{
allowedProfiles.RemoveAt(index);
continue;
}
var normalized = entry.Trim();
var canonical = normalized.ToLowerInvariant();
if (!unique.Add(canonical))
{
allowedProfiles.RemoveAt(index);
continue;
}
allowedProfiles[index] = canonical;
}
}
internal void Validate()
{
if (Enabled)
{
Normalize();
if (allowedProfiles.Count == 0)
{
throw new InvalidOperationException("Authority configuration requires at least one advisory AI remote inference profile when remote inference is enabled.");
}
}
else
{
// Ensure no stale profiles linger to avoid confusing downstream consumers.
Normalize();
}
}
}
public sealed class AuthorityTenantAdvisoryAiOptions
{
public AdvisoryAiTenantRemoteInferenceOptions RemoteInference { get; } = new();
internal void Normalize(AuthorityAdvisoryAiOptions? _) => RemoteInference.Normalize();
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions) => RemoteInference.Validate(globalOptions);
}
public sealed class AdvisoryAiTenantRemoteInferenceOptions
{
private const int MaxConsentVersionLength = 128;
private const int MaxConsentedByLength = 256;
public bool ConsentGranted { get; set; }
public string? ConsentVersion { get; set; }
public DateTimeOffset? ConsentedAt { get; set; }
public string? ConsentedBy { get; set; }
internal void Normalize()
{
ConsentVersion = string.IsNullOrWhiteSpace(ConsentVersion) ? null : ConsentVersion.Trim();
ConsentedBy = string.IsNullOrWhiteSpace(ConsentedBy) ? null : ConsentedBy.Trim();
if (ConsentedAt.HasValue)
{
ConsentedAt = ConsentedAt.Value.ToUniversalTime();
}
}
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions)
{
Normalize();
var remoteOptions = globalOptions?.RemoteInference;
if (!ConsentGranted)
{
return;
}
if (remoteOptions is null || !remoteOptions.Enabled)
{
throw new InvalidOperationException("Tenant remote inference consent cannot be granted when remote inference is disabled.");
}
if (ConsentVersion is { Length: > MaxConsentVersionLength })
{
throw new InvalidOperationException($"Tenant remote inference consentVersion must be {MaxConsentVersionLength} characters or fewer.");
}
if (ConsentedBy is { Length: > MaxConsentedByLength })
{
throw new InvalidOperationException($"Tenant remote inference consentedBy must be {MaxConsentedByLength} characters or fewer.");
}
if (remoteOptions.RequireTenantConsent)
{
if (string.IsNullOrWhiteSpace(ConsentVersion))
{
throw new InvalidOperationException("Tenant remote inference consent requires consentVersion when consentGranted is true.");
}
if (!ConsentedAt.HasValue)
{
throw new InvalidOperationException("Tenant remote inference consent requires consentedAt when consentGranted is true.");
}
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
namespace StellaOps.Configuration;
/// <summary>
/// API lifecycle controls for the Authority service.
/// </summary>
public sealed class AuthorityApiLifecycleOptions
{
/// <summary>
/// Settings for the legacy OAuth endpoint shim (/oauth/* → canonical).
/// </summary>
public AuthorityLegacyAuthEndpointOptions LegacyAuth { get; } = new();
internal void Validate()
{
LegacyAuth.Validate();
}
}
/// <summary>
/// Configuration for legacy OAuth endpoint shims and deprecation signalling.
/// </summary>
public sealed class AuthorityLegacyAuthEndpointOptions
{
private static readonly DateTimeOffset DefaultDeprecationDate = new(2025, 11, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset DefaultSunsetDate = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// Enables the legacy endpoint shim that routes /oauth/* to the canonical endpoints.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Date when clients should consider the legacy endpoints deprecated.
/// </summary>
public DateTimeOffset DeprecationDate { get; set; } = DefaultDeprecationDate;
/// <summary>
/// Date when legacy endpoints will be removed.
/// </summary>
public DateTimeOffset SunsetDate { get; set; } = DefaultSunsetDate;
/// <summary>
/// Optional documentation URL included in the Sunset link header.
/// </summary>
public string? DocumentationUrl { get; set; } = "https://docs.stella-ops.org/authority/legacy-auth";
internal void Validate()
{
if (!Enabled)
{
return;
}
var normalizedDeprecation = DeprecationDate.ToUniversalTime();
var normalizedSunset = SunsetDate.ToUniversalTime();
if (normalizedSunset <= normalizedDeprecation)
{
throw new InvalidOperationException("Legacy auth sunset date must be after the deprecation date.");
}
DeprecationDate = normalizedDeprecation;
SunsetDate = normalizedSunset;
if (!string.IsNullOrWhiteSpace(DocumentationUrl))
{
if (!Uri.TryCreate(DocumentationUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
{
throw new InvalidOperationException("Legacy auth documentation URL must be an absolute HTTP or HTTPS URL.");
}
}
}
}

View File

@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using StellaOps.Cryptography;
namespace StellaOps.Configuration;
/// <summary>
/// Notification-related configuration surfaced by the Authority host.
/// </summary>
public sealed class AuthorityNotificationsOptions
{
/// <summary>
/// DSSE ack token configuration.
/// </summary>
public AuthorityAckTokenOptions AckTokens { get; } = new();
/// <summary>
/// Webhook allowlist configuration for callback targets.
/// </summary>
public AuthorityWebhookAllowlistOptions Webhooks { get; } = new();
/// <summary>
/// Escalation guardrail configuration.
/// </summary>
public AuthorityEscalationOptions Escalation { get; } = new();
internal void Validate()
{
AckTokens.Validate();
Webhooks.Validate();
Escalation.Validate();
}
}
/// <summary>
/// Options governing signed ack token issuance.
/// </summary>
public sealed class AuthorityAckTokenOptions
{
private readonly IList<AuthoritySigningAdditionalKeyOptions> additionalKeys =
new List<AuthoritySigningAdditionalKeyOptions>();
/// <summary>
/// Determines whether ack tokens are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// DSSE payload type used for issued ack tokens.
/// </summary>
public string PayloadType { get; set; } = "application/vnd.stellaops.notify-ack-token+json";
/// <summary>
/// Default lifetime applied to tokens when a caller omits a value.
/// </summary>
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(15);
/// <summary>
/// Maximum lifetime permitted for ack tokens.
/// </summary>
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Signing algorithm identifier (defaults to ES256).
/// </summary>
public string Algorithm { get; set; } = SignatureAlgorithms.Es256;
/// <summary>
/// Signing key source used to load ack token keys.
/// </summary>
public string KeySource { get; set; } = "file";
/// <summary>
/// Active signing key identifier (kid) for ack tokens.
/// </summary>
public string ActiveKeyId { get; set; } = string.Empty;
/// <summary>
/// Path or handle to the active key material.
/// </summary>
public string KeyPath { get; set; } = string.Empty;
/// <summary>
/// Optional crypto provider hint.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// Optional JWKS cache lifetime override for ack keys.
/// </summary>
public TimeSpan JwksCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Additional (retired) keys retained for verification.
/// </summary>
public IList<AuthoritySigningAdditionalKeyOptions> AdditionalKeys => additionalKeys;
/// <summary>
/// Metadata value emitted in JWKS use field (defaults to <c>notify-ack</c>).
/// </summary>
public string KeyUse { get; set; } = "notify-ack";
internal void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(PayloadType))
{
throw new InvalidOperationException("notifications.ackTokens.payloadType must be specified when ack tokens are enabled.");
}
if (DefaultLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("notifications.ackTokens.defaultLifetime must be greater than zero.");
}
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
{
throw new InvalidOperationException("notifications.ackTokens.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
}
if (string.IsNullOrWhiteSpace(ActiveKeyId))
{
throw new InvalidOperationException("notifications.ackTokens.activeKeyId must be provided when ack tokens are enabled.");
}
if (string.IsNullOrWhiteSpace(KeyPath))
{
throw new InvalidOperationException("notifications.ackTokens.keyPath must be provided when ack tokens are enabled.");
}
if (string.IsNullOrWhiteSpace(KeySource))
{
KeySource = "file";
}
if (string.IsNullOrWhiteSpace(Algorithm))
{
Algorithm = SignatureAlgorithms.Es256;
}
if (string.IsNullOrWhiteSpace(KeyUse))
{
KeyUse = "notify-ack";
}
foreach (var additional in AdditionalKeys)
{
additional.Validate(KeySource);
}
if (JwksCacheLifetime <= TimeSpan.Zero || JwksCacheLifetime > TimeSpan.FromHours(1))
{
throw new InvalidOperationException("notifications.ackTokens.jwksCacheLifetime must be between 00:00:01 and 01:00:00.");
}
}
}
/// <summary>
/// Options controlling webhook allowlists for ack callbacks.
/// </summary>
public sealed class AuthorityWebhookAllowlistOptions
{
private readonly IList<string> allowedHosts = new List<string>();
private readonly IList<string> allowedSchemes = new List<string> { "https" };
/// <summary>
/// Determines whether allowlist enforcement is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Hostnames or wildcard suffixes permitted for webhook callbacks (e.g. <c>hooks.slack.com</c>, <c>*.pagerduty.com</c>).
/// </summary>
public IList<string> AllowedHosts => allowedHosts;
/// <summary>
/// Allowed URI schemes for webhook callbacks (defaults to <c>https</c>).
/// </summary>
public IList<string> AllowedSchemes => allowedSchemes;
internal void Validate()
{
if (!Enabled)
{
return;
}
if (allowedHosts.Count == 0)
{
throw new InvalidOperationException("notifications.webhooks.allowedHosts must include at least one host when enabled.");
}
NormalizeList(allowedHosts);
NormalizeList(allowedSchemes);
if (allowedSchemes.Count == 0)
{
allowedSchemes.Add("https");
}
}
private static void NormalizeList(IList<string> values)
{
for (var i = values.Count - 1; i >= 0; i--)
{
var current = values[i];
if (string.IsNullOrWhiteSpace(current))
{
values.RemoveAt(i);
continue;
}
values[i] = current.Trim();
}
}
}
/// <summary>
/// Options controlling escalation enforcement for acknowledgement flows.
/// </summary>
public sealed class AuthorityEscalationOptions
{
/// <summary>
/// Scope required to mint or execute escalation-bearing ack tokens.
/// </summary>
public string Scope { get; set; } = "notify.escalate";
/// <summary>
/// When true, escalation requires the caller to also possess <c>notify.admin</c>.
/// </summary>
public bool RequireAdminScope { get; set; } = true;
internal void Validate()
{
if (string.IsNullOrWhiteSpace(Scope))
{
throw new InvalidOperationException("notifications.escalation.scope must be specified.");
}
Scope = Scope.Trim().ToLowerInvariant();
}
}