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,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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.IssuerDirectory.Client;
|
||||
|
||||
public interface IIssuerDirectoryClient
|
||||
{
|
||||
ValueTask<IReadOnlyList<IssuerKeyModel>> GetIssuerKeysAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IssuerTrustResponseModel> GetIssuerTrustAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Client;
|
||||
|
||||
internal sealed class IssuerDirectoryClient : IIssuerDirectoryClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IssuerDirectoryClientOptions _options;
|
||||
private readonly ILogger<IssuerDirectoryClient> _logger;
|
||||
|
||||
public IssuerDirectoryClient(
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache,
|
||||
IOptions<IssuerDirectoryClientOptions> options,
|
||||
ILogger<IssuerDirectoryClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_options = options.Value;
|
||||
_options.Validate();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<IssuerKeyModel>> GetIssuerKeysAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var cacheKey = CacheKey("keys", tenantId, issuerId, includeGlobal.ToString(CultureInfo.InvariantCulture));
|
||||
if (_cache.TryGetValue(cacheKey, out IReadOnlyList<IssuerKeyModel>? cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/keys?includeGlobal={includeGlobal.ToString().ToLowerInvariant()}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
request.Headers.TryAddWithoutValidation(_options.TenantHeader, tenantId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Issuer Directory key lookup failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
|
||||
issuerId,
|
||||
tenantId,
|
||||
response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<List<IssuerKeyModel>>(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<IssuerKeyModel> result = payload?.ToArray() ?? Array.Empty<IssuerKeyModel>();
|
||||
_cache.Set(cacheKey, result, _options.Cache.Keys);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask<IssuerTrustResponseModel> GetIssuerTrustAsync(
|
||||
string tenantId,
|
||||
string issuerId,
|
||||
bool includeGlobal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(issuerId);
|
||||
|
||||
var cacheKey = CacheKey("trust", tenantId, issuerId, includeGlobal.ToString(CultureInfo.InvariantCulture));
|
||||
if (_cache.TryGetValue(cacheKey, out IssuerTrustResponseModel? cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/trust?includeGlobal={includeGlobal.ToString().ToLowerInvariant()}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
request.Headers.TryAddWithoutValidation(_options.TenantHeader, tenantId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Issuer Directory trust lookup failed for {IssuerId} (tenant={TenantId}) {StatusCode}",
|
||||
issuerId,
|
||||
tenantId,
|
||||
response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<IssuerTrustResponseModel>(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false) ?? new IssuerTrustResponseModel(null, null, 0m);
|
||||
|
||||
_cache.Set(cacheKey, payload, _options.Cache.Trust);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static string CacheKey(string prefix, params string[] parts)
|
||||
{
|
||||
if (parts is null || parts.Length == 0)
|
||||
{
|
||||
return prefix;
|
||||
}
|
||||
|
||||
var segments = new string[1 + parts.Length];
|
||||
segments[0] = prefix;
|
||||
Array.Copy(parts, 0, segments, 1, parts.Length);
|
||||
return string.Join('|', segments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.IssuerDirectory.Client;
|
||||
|
||||
public sealed class IssuerDirectoryClientOptions
|
||||
{
|
||||
public const string SectionName = "IssuerDirectory:Client";
|
||||
|
||||
public Uri? BaseAddress { get; set; }
|
||||
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
|
||||
|
||||
public IssuerDirectoryCacheOptions Cache { get; set; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory client base address must be configured.");
|
||||
}
|
||||
|
||||
if (!BaseAddress.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory client base address must be absolute.");
|
||||
}
|
||||
|
||||
if (HttpTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory client timeout must be positive.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(TenantHeader))
|
||||
{
|
||||
throw new InvalidOperationException("IssuerDirectory tenant header must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IssuerDirectoryCacheOptions
|
||||
{
|
||||
public TimeSpan Keys { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan Trust { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Client;
|
||||
|
||||
public sealed record IssuerKeyModel(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("issuerId")] string IssuerId,
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("materialFormat")] string MaterialFormat,
|
||||
[property: JsonPropertyName("materialValue")] string MaterialValue,
|
||||
[property: JsonPropertyName("fingerprint")] string Fingerprint,
|
||||
[property: JsonPropertyName("expiresAtUtc")] DateTimeOffset? ExpiresAtUtc,
|
||||
[property: JsonPropertyName("retiredAtUtc")] DateTimeOffset? RetiredAtUtc,
|
||||
[property: JsonPropertyName("revokedAtUtc")] DateTimeOffset? RevokedAtUtc,
|
||||
[property: JsonPropertyName("replacesKeyId")] string? ReplacesKeyId);
|
||||
|
||||
public sealed record IssuerTrustOverrideModel(
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("updatedAtUtc")] DateTimeOffset UpdatedAtUtc,
|
||||
[property: JsonPropertyName("updatedBy")] string UpdatedBy,
|
||||
[property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc,
|
||||
[property: JsonPropertyName("createdBy")] string CreatedBy);
|
||||
|
||||
public sealed record IssuerTrustResponseModel(
|
||||
[property: JsonPropertyName("tenantOverride")] IssuerTrustOverrideModel? TenantOverride,
|
||||
[property: JsonPropertyName("globalOverride")] IssuerTrustOverrideModel? GlobalOverride,
|
||||
[property: JsonPropertyName("effectiveWeight")] decimal EffectiveWeight);
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Client;
|
||||
|
||||
public static class IssuerDirectoryClientServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddIssuerDirectoryClient(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<IssuerDirectoryClientOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
return services.AddIssuerDirectoryClient(configuration.GetSection(IssuerDirectoryClientOptions.SectionName), configure);
|
||||
}
|
||||
|
||||
public static IServiceCollection AddIssuerDirectoryClient(
|
||||
this IServiceCollection services,
|
||||
IConfigurationSection configurationSection,
|
||||
Action<IssuerDirectoryClientOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configurationSection);
|
||||
|
||||
services.AddMemoryCache();
|
||||
services.AddOptions<IssuerDirectoryClientOptions>()
|
||||
.Bind(configurationSection)
|
||||
.PostConfigure(options => configure?.Invoke(options))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<IIssuerDirectoryClient, IssuerDirectoryClient>((provider, client) =>
|
||||
{
|
||||
var opts = provider.GetRequiredService<IOptions<IssuerDirectoryClientOptions>>().Value;
|
||||
opts.Validate();
|
||||
client.BaseAddress = opts.BaseAddress;
|
||||
client.Timeout = opts.HttpTimeout;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,8 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Configuration.Tests;
|
||||
|
||||
@@ -19,37 +20,60 @@ public class StellaOpsAuthorityOptionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Normalises_Collections()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
options.PluginDirectories.Add(" ./plugins ");
|
||||
options.PluginDirectories.Add("./plugins");
|
||||
options.PluginDirectories.Add("./other");
|
||||
|
||||
options.BypassNetworks.Add(" 10.0.0.0/24 ");
|
||||
options.BypassNetworks.Add("10.0.0.0/24");
|
||||
options.BypassNetworks.Add("192.168.0.0/16");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new[] { "./plugins", "./other" }, options.PluginDirectories);
|
||||
Assert.Equal(new[] { "10.0.0.0/24", "192.168.0.0/16" }, options.BypassNetworks);
|
||||
}
|
||||
public void Validate_Normalises_Collections()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
|
||||
options.PluginDirectories.Add(" ./plugins ");
|
||||
options.PluginDirectories.Add("./plugins");
|
||||
options.PluginDirectories.Add("./other");
|
||||
|
||||
options.BypassNetworks.Add(" 10.0.0.0/24 ");
|
||||
options.BypassNetworks.Add("10.0.0.0/24");
|
||||
options.BypassNetworks.Add("192.168.0.0/16");
|
||||
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add(" cloud-openai ");
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("CLOUD-OPENAI");
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("sovereign-local");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new[] { "./plugins", "./other" }, options.PluginDirectories);
|
||||
Assert.Equal(new[] { "10.0.0.0/24", "192.168.0.0/16" }, options.BypassNetworks);
|
||||
Assert.Equal(new[] { "cloud-openai", "sovereign-local" }, options.AdvisoryAi.RemoteInference.AllowedProfiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_RemoteInferenceEnabledWithoutProfiles()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("remote inference", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Normalises_PluginDescriptors()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
public void Validate_Normalises_PluginDescriptors()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
@@ -72,8 +96,73 @@ public class StellaOpsAuthorityOptionsTests
|
||||
var normalized = options.Plugins.Descriptors["standard"];
|
||||
Assert.Equal("standard.yaml", normalized.ConfigFile);
|
||||
Assert.Single(normalized.Capabilities);
|
||||
Assert.Equal("password", normalized.Capabilities[0]);
|
||||
}
|
||||
Assert.Equal("password", normalized.Capabilities[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Allows_TenantRemoteInferenceConsent_WhenConfigured()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
|
||||
var tenant = new AuthorityTenantOptions
|
||||
{
|
||||
Id = "tenant-default",
|
||||
DisplayName = "Tenant Default"
|
||||
};
|
||||
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10";
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z", CultureInfo.InvariantCulture);
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com";
|
||||
|
||||
options.Tenants.Add(tenant);
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal("2025-10", tenant.AdvisoryAi.RemoteInference.ConsentVersion);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-10-31T12:34:56Z", CultureInfo.InvariantCulture), tenant.AdvisoryAi.RemoteInference.ConsentedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_TenantRemoteInferenceConsentMissingVersion()
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.stella-ops.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
|
||||
options.Signing.ActiveKeyId = "test-key";
|
||||
options.Signing.KeyPath = "/tmp/test-key.pem";
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
|
||||
var tenant = new AuthorityTenantOptions
|
||||
{
|
||||
Id = "tenant-default",
|
||||
DisplayName = "Tenant Default"
|
||||
};
|
||||
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z", CultureInfo.InvariantCulture);
|
||||
|
||||
options.Tenants.Add(tenant);
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("consentVersion", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_StorageConnectionStringMissing()
|
||||
|
||||
Reference in New Issue
Block a user