stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
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();
}
}
}

View File

@@ -0,0 +1,67 @@
using System;
namespace StellaOps.Configuration;
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,65 @@
using StellaOps.Cryptography;
using System;
namespace StellaOps.Configuration;
public sealed partial class AuthorityAckTokenOptions
{
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.");
}
}
}

View File

@@ -0,0 +1,75 @@
using StellaOps.Cryptography;
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <summary>
/// Options governing signed ack token issuance.
/// </summary>
public sealed partial 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";
}

View File

@@ -1,6 +1,3 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <summary>
@@ -20,144 +17,3 @@ public sealed class AuthorityAdvisoryAiOptions
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,11 @@
namespace StellaOps.Configuration;
public sealed class AuthorityAirGapOptions
{
public AuthoritySealedModeOptions SealedMode { get; } = new();
internal void Validate()
{
SealedMode.Validate();
}
}

View File

@@ -23,8 +23,8 @@ public sealed class AuthorityApiLifecycleOptions
/// </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);
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.
@@ -34,12 +34,12 @@ public sealed class AuthorityLegacyAuthEndpointOptions
/// <summary>
/// Date when clients should consider the legacy endpoints deprecated.
/// </summary>
public DateTimeOffset DeprecationDate { get; set; } = DefaultDeprecationDate;
public DateTimeOffset DeprecationDate { get; set; } = _defaultDeprecationDate;
/// <summary>
/// Date when legacy endpoints will be removed.
/// </summary>
public DateTimeOffset SunsetDate { get; set; } = DefaultSunsetDate;
public DateTimeOffset SunsetDate { get; set; } = _defaultSunsetDate;
/// <summary>
/// Optional documentation URL included in the Sunset link header.
@@ -74,4 +74,3 @@ public sealed class AuthorityLegacyAuthEndpointOptions
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthorityBootstrapOptions
{
/// <summary>
/// Enables or disables bootstrap administrative APIs.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// API key required when invoking bootstrap endpoints.
/// </summary>
public string? ApiKey { get; set; } = string.Empty;
/// <summary>
/// Default identity provider used when none is specified in bootstrap requests.
/// </summary>
public string? DefaultIdentityProvider { get; set; } = "standard";
internal void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ApiKey))
{
throw new InvalidOperationException("Authority bootstrap configuration requires an API key when enabled.");
}
if (string.IsNullOrWhiteSpace(DefaultIdentityProvider))
{
throw new InvalidOperationException("Authority bootstrap configuration requires a default identity provider name when enabled.");
}
}
}

View File

@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Configuration;
public sealed class AuthorityDelegationOptions
{
private readonly IList<AuthorityServiceAccountSeedOptions> _serviceAccounts = new List<AuthorityServiceAccountSeedOptions>();
private readonly Dictionary<string, AuthorityTenantDelegationOptions> _tenantOverrides = new(StringComparer.OrdinalIgnoreCase);
public AuthorityDelegationQuotaOptions Quotas { get; } = new();
public IList<AuthorityServiceAccountSeedOptions> ServiceAccounts => (IList<AuthorityServiceAccountSeedOptions>)_serviceAccounts;
internal void NormalizeAndValidate(IList<AuthorityTenantOptions> tenants)
{
Quotas.Validate(nameof(Quotas));
var tenantIds = tenants is { Count: > 0 }
? tenants
.Where(static tenant => !string.IsNullOrWhiteSpace(tenant.Id))
.Select(static tenant => tenant.Id.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenAccounts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_tenantOverrides.Clear();
foreach (var tenant in tenants)
{
if (string.IsNullOrWhiteSpace(tenant.Id))
{
continue;
}
var normalizedTenant = tenant.Id.Trim().ToLowerInvariant();
_tenantOverrides[normalizedTenant] = tenant.Delegation;
}
foreach (var account in _serviceAccounts)
{
account.Normalize();
account.Validate(tenantIds);
if (!seenAccounts.Add(account.AccountId))
{
throw new InvalidOperationException($"Delegation configuration contains duplicate service account id '{account.AccountId}'.");
}
}
}
public int ResolveMaxActiveTokens(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Quotas.MaxActiveTokens;
}
var normalized = tenantId.Trim().ToLowerInvariant();
if (_tenantOverrides.TryGetValue(normalized, out var options))
{
return options.ResolveMaxActiveTokens(this);
}
return Quotas.MaxActiveTokens;
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthorityDelegationQuotaOptions
{
public int MaxActiveTokens { get; set; } = 50;
internal void Validate(string propertyName)
{
if (MaxActiveTokens <= 0)
{
throw new InvalidOperationException($"Authority delegation configuration requires {propertyName}.{nameof(MaxActiveTokens)} to be greater than zero.");
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Configuration;
public sealed class AuthorityDpopNonceOptions
{
private readonly HashSet<string> _requiredAudiences = new(StringComparer.OrdinalIgnoreCase)
{
"signer",
"attestor"
};
public bool Enabled { get; set; } = true;
public TimeSpan Ttl { get; set; } = TimeSpan.FromMinutes(10);
public int MaxIssuancePerMinute { get; set; } = 120;
public string Store { get; set; } = "memory";
public string? RedisConnectionString { get; set; }
public ISet<string> RequiredAudiences => _requiredAudiences;
public IReadOnlySet<string> NormalizedAudiences { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
internal void Validate()
{
if (Ttl <= TimeSpan.Zero)
{
throw new InvalidOperationException("Dpop.Nonce.Ttl must be greater than zero.");
}
if (MaxIssuancePerMinute < 1)
{
throw new InvalidOperationException("Dpop.Nonce.MaxIssuancePerMinute must be at least 1.");
}
if (string.IsNullOrWhiteSpace(Store))
{
throw new InvalidOperationException("Dpop.Nonce.Store must be specified.");
}
Store = Store.Trim().ToLowerInvariant();
if (Store is not ("memory" or "redis"))
{
throw new InvalidOperationException("Dpop.Nonce.Store must be either 'memory' or 'redis'.");
}
if (Store == "redis" && string.IsNullOrWhiteSpace(RedisConnectionString))
{
throw new InvalidOperationException("Dpop.Nonce.RedisConnectionString must be provided when using the 'redis' store.");
}
var normalizedAudiences = _requiredAudiences
.Select(static aud => aud.Trim())
.Where(static aud => aud.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (normalizedAudiences.Count == 0)
{
throw new InvalidOperationException("Dpop.Nonce.RequiredAudiences must include at least one audience.");
}
_requiredAudiences.Clear();
foreach (var audience in normalizedAudiences)
{
_requiredAudiences.Add(audience);
}
NormalizedAudiences = normalizedAudiences;
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Configuration;
public sealed class AuthorityDpopOptions
{
private readonly HashSet<string> _allowedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"ES256",
"ES384"
};
public bool Enabled { get; set; }
/// <summary>
/// Allows temporarily bypassing DPoP enforcement (for emergency drills only).
/// </summary>
public bool AllowTemporaryBypass { get; set; }
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
public ISet<string> AllowedAlgorithms => _allowedAlgorithms;
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = new HashSet<string>(StringComparer.Ordinal);
public AuthorityDpopNonceOptions Nonce { get; } = new();
internal void Validate()
{
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Dpop.ProofLifetime must be greater than zero.");
}
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Dpop.AllowedClockSkew must be between 0 and 5 minutes.");
}
if (ReplayWindow < TimeSpan.Zero)
{
throw new InvalidOperationException("Dpop.ReplayWindow must be greater than or equal to zero.");
}
if (_allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one DPoP algorithm must be configured.");
}
NormalizedAlgorithms = _allowedAlgorithms
.Select(static alg => alg.Trim().ToUpperInvariant())
.Where(static alg => alg.Length > 0)
.ToHashSet(StringComparer.Ordinal);
if (NormalizedAlgorithms.Count == 0)
{
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
}
Nonce.Validate();
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Threading.RateLimiting;
namespace StellaOps.Configuration;
public sealed class AuthorityEndpointRateLimitOptions
{
/// <summary>
/// Gets or sets a value indicating whether rate limiting is enabled for the endpoint.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Maximum number of requests allowed within the configured window.
/// </summary>
public int PermitLimit { get; set; } = 60;
/// <summary>
/// Size of the fixed window applied to the rate limiter.
/// </summary>
public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Maximum number of queued requests awaiting permits.
/// </summary>
public int QueueLimit { get; set; } = 0;
/// <summary>
/// Ordering strategy for queued requests.
/// </summary>
public QueueProcessingOrder QueueProcessingOrder { get; set; } = QueueProcessingOrder.OldestFirst;
internal void Validate(string name)
{
if (!Enabled)
{
return;
}
if (PermitLimit <= 0)
{
throw new InvalidOperationException($"Authority rate limiting '{name}' requires permitLimit to be greater than zero.");
}
if (QueueLimit < 0)
{
throw new InvalidOperationException($"Authority rate limiting '{name}' queueLimit cannot be negative.");
}
if (Window <= TimeSpan.Zero || Window > TimeSpan.FromHours(1))
{
throw new InvalidOperationException($"Authority rate limiting '{name}' window must be greater than zero and no more than one hour.");
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
namespace StellaOps.Configuration;
/// <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();
}
}

View File

@@ -0,0 +1,55 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthorityExceptionRoutingTemplateOptions
{
/// <summary>
/// Stable identifier referenced by policy packs.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// Authority approval route identifier used by downstream services.
/// </summary>
public string? AuthorityRouteId { get; set; }
/// <summary>
/// Indicates whether the approval route enforces multi-factor authentication.
/// </summary>
public bool RequireMfa { get; set; }
/// <summary>
/// Optional human-readable description for operators.
/// </summary>
public string? Description { get; set; }
internal void Normalize()
{
Id = string.IsNullOrWhiteSpace(Id) ? null : Id.Trim();
AuthorityRouteId = string.IsNullOrWhiteSpace(AuthorityRouteId) ? null : AuthorityRouteId.Trim();
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim();
}
internal void Validate()
{
if (string.IsNullOrEmpty(Id))
{
throw new InvalidOperationException("Authority exception routing templates require an id.");
}
if (string.IsNullOrEmpty(AuthorityRouteId))
{
throw new InvalidOperationException($"Authority exception routing template '{Id}' requires authorityRouteId.");
}
}
internal AuthorityExceptionRoutingTemplate ToImmutable()
=> new(Id!, AuthorityRouteId!, RequireMfa, Description);
}
public sealed record AuthorityExceptionRoutingTemplate(
string Id,
string AuthorityRouteId,
bool RequireMfa,
string? Description);

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Configuration;
public sealed class AuthorityExceptionsOptions
{
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly List<AuthorityExceptionRoutingTemplateOptions> _routingTemplates = new();
/// <summary>
/// Declarative routing templates used to coordinate exception approvals.
/// </summary>
public IList<AuthorityExceptionRoutingTemplateOptions> RoutingTemplates => _routingTemplates;
/// <summary>
/// Normalized lookup of routing templates keyed by template identifier.
/// </summary>
public IReadOnlyDictionary<string, AuthorityExceptionRoutingTemplate> NormalizedRoutingTemplates { get; private set; }
= new Dictionary<string, AuthorityExceptionRoutingTemplate>(_ordinalIgnoreCase);
/// <summary>
/// Indicates whether any exception approval routes require MFA participation.
/// </summary>
public bool RequiresMfaForApprovals => _routingTemplates.Any(template => template is { RequireMfa: true });
internal void Validate()
{
if (_routingTemplates.Count == 0)
{
NormalizedRoutingTemplates = new Dictionary<string, AuthorityExceptionRoutingTemplate>(_ordinalIgnoreCase);
return;
}
var normalized = new Dictionary<string, AuthorityExceptionRoutingTemplate>(_ordinalIgnoreCase);
foreach (var templateOptions in _routingTemplates)
{
if (templateOptions is null)
{
throw new InvalidOperationException("Authority exception routing template entries must not be null.");
}
templateOptions.Normalize();
templateOptions.Validate();
var template = templateOptions.ToImmutable();
if (!normalized.TryAdd(template.Id, template))
{
throw new InvalidOperationException($"Authority exception routing template '{template.Id}' is configured more than once.");
}
}
NormalizedRoutingTemplates = normalized;
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Configuration;
public sealed class AuthorityMtlsOptions
{
private readonly HashSet<string> _enforceForAudiences = new(StringComparer.OrdinalIgnoreCase)
{
"signer"
};
private readonly HashSet<string> _allowedSanTypes = new(StringComparer.OrdinalIgnoreCase)
{
"dns",
"uri"
};
public bool Enabled { get; set; }
public bool RequireChainValidation { get; set; } = true;
public TimeSpan RotationGrace { get; set; } = TimeSpan.FromMinutes(15);
public ISet<string> EnforceForAudiences => _enforceForAudiences;
public IReadOnlySet<string> NormalizedAudiences { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public IList<string> AllowedCertificateAuthorities { get; } = new List<string>();
public IList<string> AllowedSubjectPatterns { get; } = new List<string>();
public ISet<string> AllowedSanTypes => _allowedSanTypes;
public IReadOnlyList<Regex> NormalizedSubjectPatterns { get; private set; } = Array.Empty<Regex>();
public IReadOnlySet<string> NormalizedSanTypes { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
internal void Validate()
{
if (RotationGrace < TimeSpan.Zero)
{
throw new InvalidOperationException("Mtls.RotationGrace must be non-negative.");
}
NormalizedAudiences = _enforceForAudiences
.Select(static aud => aud.Trim())
.Where(static aud => aud.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (Enabled && NormalizedAudiences.Count == 0)
{
throw new InvalidOperationException("Mtls.EnforceForAudiences must include at least one audience when enabled.");
}
if (AllowedCertificateAuthorities.Any(static path => string.IsNullOrWhiteSpace(path)))
{
throw new InvalidOperationException("Mtls.AllowedCertificateAuthorities entries must not be empty.");
}
NormalizedSanTypes = _allowedSanTypes
.Select(static value => value.Trim())
.Where(static value => value.Length > 0)
.Select(static value => value.ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (Enabled && NormalizedSanTypes.Count == 0)
{
throw new InvalidOperationException("Mtls.AllowedSanTypes must include at least one entry when enabled.");
}
var compiledPatterns = new List<Regex>(AllowedSubjectPatterns.Count);
foreach (var pattern in AllowedSubjectPatterns)
{
if (string.IsNullOrWhiteSpace(pattern))
{
throw new InvalidOperationException("Mtls.AllowedSubjectPatterns entries must not be empty.");
}
try
{
compiledPatterns.Add(new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)));
}
catch (RegexParseException ex)
{
throw new InvalidOperationException($"Mtls.AllowedSubjectPatterns entry '{pattern}' is not a valid regular expression.", ex);
}
}
NormalizedSubjectPatterns = compiledPatterns;
}
}

View File

@@ -1,9 +1,3 @@
using StellaOps.Cryptography;
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <summary>
@@ -33,216 +27,3 @@ public sealed class AuthorityNotificationsOptions
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();
}
}

View File

@@ -1,99 +0,0 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Authority.Plugins.Abstractions;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace StellaOps.Configuration;
/// <summary>
/// Analyses Authority plugin configurations for common security issues.
/// </summary>
public static class AuthorityPluginConfigurationAnalyzer
{
private const int BaselineMinimumLength = 12;
private const bool BaselineRequireUppercase = true;
private const bool BaselineRequireLowercase = true;
private const bool BaselineRequireDigit = true;
private const bool BaselineRequireSymbol = true;
/// <summary>
/// Evaluates plugin contexts and returns diagnostics describing potential misconfigurations.
/// </summary>
/// <param name="contexts">Plugin contexts produced by <see cref="AuthorityPluginConfigurationLoader"/>.</param>
/// <returns>Diagnostics describing any detected issues.</returns>
public static IReadOnlyList<AuthorityConfigurationDiagnostic> Analyze(IEnumerable<AuthorityPluginContext> contexts)
{
ArgumentNullException.ThrowIfNull(contexts);
var diagnostics = new List<AuthorityConfigurationDiagnostic>();
foreach (var context in contexts)
{
if (context is null)
{
continue;
}
if (string.Equals(context.Manifest.AssemblyName, "StellaOps.Authority.Plugin.Standard", StringComparison.OrdinalIgnoreCase))
{
AnalyzeStandardPlugin(context, diagnostics);
}
}
return diagnostics;
}
private static void AnalyzeStandardPlugin(AuthorityPluginContext context, ICollection<AuthorityConfigurationDiagnostic> diagnostics)
{
var section = context.Configuration.GetSection("passwordPolicy");
if (!section.Exists())
{
return;
}
int minLength = section.GetValue("minimumLength", BaselineMinimumLength);
bool requireUppercase = section.GetValue("requireUppercase", BaselineRequireUppercase);
bool requireLowercase = section.GetValue("requireLowercase", BaselineRequireLowercase);
bool requireDigit = section.GetValue("requireDigit", BaselineRequireDigit);
bool requireSymbol = section.GetValue("requireSymbol", BaselineRequireSymbol);
var deviations = new List<string>();
if (minLength < BaselineMinimumLength)
{
deviations.Add($"minimum length {minLength.ToString(CultureInfo.InvariantCulture)} < {BaselineMinimumLength}");
}
if (!requireUppercase && BaselineRequireUppercase)
{
deviations.Add("uppercase requirement disabled");
}
if (!requireLowercase && BaselineRequireLowercase)
{
deviations.Add("lowercase requirement disabled");
}
if (!requireDigit && BaselineRequireDigit)
{
deviations.Add("digit requirement disabled");
}
if (!requireSymbol && BaselineRequireSymbol)
{
deviations.Add("symbol requirement disabled");
}
if (deviations.Count == 0)
{
return;
}
var message = $"Password policy for plugin '{context.Manifest.Name}' weakens host defaults: {string.Join(", ", deviations)}.";
diagnostics.Add(new AuthorityConfigurationDiagnostic(context.Manifest.Name, AuthorityConfigurationDiagnosticSeverity.Warning, message));
}
}

View File

@@ -1,102 +0,0 @@
using Microsoft.Extensions.Configuration;
using NetEscapades.Configuration.Yaml;
using StellaOps.Authority.Plugins.Abstractions;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace StellaOps.Configuration;
/// <summary>
/// Utility helpers for loading Authority plugin configuration manifests.
/// </summary>
public static class AuthorityPluginConfigurationLoader
{
/// <summary>
/// Loads plugin configuration files based on the supplied Authority options.
/// </summary>
/// <param name="options">Authority configuration containing plugin descriptors.</param>
/// <param name="basePath">Application base path used to resolve relative directories.</param>
/// <param name="configureBuilder">Optional hook to customise per-plugin configuration builder.</param>
public static IReadOnlyList<AuthorityPluginContext> Load(
StellaOpsAuthorityOptions options,
string basePath,
Action<IConfigurationBuilder>? configureBuilder = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(basePath);
var descriptorPairs = options.Plugins.Descriptors
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (descriptorPairs.Length == 0)
{
return Array.Empty<AuthorityPluginContext>();
}
var configurationDirectory = ResolveConfigurationDirectory(options.Plugins.ConfigurationDirectory, basePath);
var contexts = new List<AuthorityPluginContext>(descriptorPairs.Length);
foreach (var (name, descriptor) in descriptorPairs)
{
var configPath = ResolveConfigPath(configurationDirectory, descriptor.ConfigFile);
var optional = !descriptor.Enabled;
if (!optional && !File.Exists(configPath))
{
throw new FileNotFoundException($"Required Authority plugin configuration '{configPath}' was not found.", configPath);
}
var builder = new ConfigurationBuilder();
var builderBasePath = Path.GetDirectoryName(configPath);
if (!string.IsNullOrEmpty(builderBasePath) && Directory.Exists(builderBasePath))
{
builder.SetBasePath(builderBasePath);
}
configureBuilder?.Invoke(builder);
builder.AddYamlFile(configPath, optional: optional, reloadOnChange: false);
var configuration = builder.Build();
var manifest = descriptor.ToManifest(name, configPath);
contexts.Add(new AuthorityPluginContext(manifest, configuration));
}
return contexts;
}
private static string ResolveConfigurationDirectory(string configurationDirectory, string basePath)
{
if (string.IsNullOrWhiteSpace(configurationDirectory))
{
return Path.GetFullPath(basePath);
}
var directory = configurationDirectory;
if (!Path.IsPathRooted(directory))
{
directory = Path.Combine(basePath, directory);
}
return Path.GetFullPath(directory);
}
private static string ResolveConfigPath(string configurationDirectory, string? configFile)
{
if (string.IsNullOrWhiteSpace(configFile))
{
throw new InvalidOperationException("Authority plugin descriptor must specify a configFile.");
}
if (Path.IsPathRooted(configFile))
{
return Path.GetFullPath(configFile);
}
return Path.GetFullPath(Path.Combine(configurationDirectory, configFile));
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class AuthorityPluginDescriptorOptions
{
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly List<string> _capabilities = new();
private readonly Dictionary<string, string?> _metadata = new(_ordinalIgnoreCase);
/// <summary>
/// Logical type identifier for the plugin (e.g. standard, ldap).
/// </summary>
public string? Type { get; set; }
/// <summary>
/// Name of the plugin assembly (without file extension).
/// </summary>
public string? AssemblyName { get; set; }
/// <summary>
/// Optional explicit assembly path override; relative paths resolve against plugin directories.
/// </summary>
public string? AssemblyPath { get; set; }
/// <summary>
/// Indicates whether the plugin should be enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Plugin capability hints surfaced to the Authority host.
/// </summary>
public IList<string> Capabilities => _capabilities;
/// <summary>
/// Optional metadata (string key/value) passed to plugin implementations.
/// </summary>
public IDictionary<string, string?> Metadata => _metadata;
/// <summary>
/// Relative path to the plugin-specific configuration file (defaults to &lt;pluginName&gt;.yaml).
/// </summary>
public string? ConfigFile { get; set; }
internal void Normalize(string pluginName)
{
if (string.IsNullOrWhiteSpace(ConfigFile))
{
ConfigFile = $"{pluginName}.yaml";
}
else
{
ConfigFile = ConfigFile.Trim();
}
Type = string.IsNullOrWhiteSpace(Type) ? pluginName : Type.Trim();
if (!string.IsNullOrWhiteSpace(AssemblyName))
{
AssemblyName = AssemblyName.Trim();
}
if (!string.IsNullOrWhiteSpace(AssemblyPath))
{
AssemblyPath = AssemblyPath.Trim();
}
if (_capabilities.Count > 0)
{
var seen = new HashSet<string>(_ordinalIgnoreCase);
var unique = new List<string>(_capabilities.Count);
foreach (var entry in _capabilities)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var canonical = entry.Trim().ToLowerInvariant();
if (seen.Add(canonical))
{
unique.Add(canonical);
}
}
unique.Sort(StringComparer.Ordinal);
_capabilities.Clear();
_capabilities.AddRange(unique);
}
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Configuration;
public sealed partial class AuthorityPluginDescriptorOptions
{
private static readonly HashSet<string> _allowedCapabilities = new(
new[]
{
"password",
"mfa",
"clientProvisioning",
"bootstrap"
},
_ordinalIgnoreCase);
internal void Validate(string pluginName)
{
if (string.IsNullOrWhiteSpace(AssemblyName) && string.IsNullOrWhiteSpace(AssemblyPath))
{
throw new InvalidOperationException($"Authority plugin '{pluginName}' must define either assemblyName or assemblyPath.");
}
if (string.IsNullOrWhiteSpace(ConfigFile))
{
throw new InvalidOperationException($"Authority plugin '{pluginName}' must define a configFile.");
}
if (Path.GetFileName(ConfigFile) != ConfigFile && Path.IsPathRooted(ConfigFile) && !File.Exists(ConfigFile))
{
throw new InvalidOperationException($"Authority plugin '{pluginName}' specifies configFile '{ConfigFile}' which does not exist.");
}
foreach (var capability in _capabilities)
{
if (!_allowedCapabilities.Contains(capability))
{
throw new InvalidOperationException($"Authority plugin '{pluginName}' declares unknown capability '{capability}'. Allowed values: password, mfa, clientProvisioning, bootstrap.");
}
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Configuration;
public sealed class AuthorityPluginSettings
{
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
/// <summary>
/// Directory containing per-plugin configuration manifests (relative paths resolved against application base path).
/// </summary>
public string ConfigurationDirectory { get; set; } = "../etc/authority.plugins";
/// <summary>
/// Declarative descriptors for Authority plugins (keyed by logical plugin name).
/// </summary>
public IDictionary<string, AuthorityPluginDescriptorOptions> Descriptors { get; } =
new Dictionary<string, AuthorityPluginDescriptorOptions>(_ordinalIgnoreCase);
internal void NormalizeAndValidate()
{
if (Descriptors.Count == 0)
{
return;
}
foreach (var (name, descriptor) in Descriptors.ToArray())
{
if (descriptor is null)
{
throw new InvalidOperationException($"Authority plugin descriptor '{name}' is null.");
}
descriptor.Normalize(name);
descriptor.Validate(name);
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthorityRateLimitingOptions
{
public AuthorityRateLimitingOptions()
{
Token = new AuthorityEndpointRateLimitOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
};
Authorize = new AuthorityEndpointRateLimitOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 10
};
Internal = new AuthorityEndpointRateLimitOptions
{
Enabled = false,
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
};
}
/// <summary>
/// Rate limiting configuration applied to the /token endpoint.
/// </summary>
public AuthorityEndpointRateLimitOptions Token { get; }
/// <summary>
/// Rate limiting configuration applied to the /authorize endpoint.
/// </summary>
public AuthorityEndpointRateLimitOptions Authorize { get; }
/// <summary>
/// Rate limiting configuration applied to /internal endpoints.
/// </summary>
public AuthorityEndpointRateLimitOptions Internal { get; }
internal void Validate()
{
Token.Validate(nameof(Token));
Authorize.Validate(nameof(Authorize));
Internal.Validate(nameof(Internal));
}
}

View File

@@ -0,0 +1,57 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthoritySealedModeOptions
{
private static readonly TimeSpan _defaultMaxEvidenceAge = TimeSpan.FromHours(6);
private static readonly TimeSpan _defaultCacheLifetime = TimeSpan.FromMinutes(1);
/// <summary>
/// Enables sealed-mode enforcement for clients that declare the requirement.
/// </summary>
public bool EnforcementEnabled { get; set; }
/// <summary>
/// Path to the latest authority-sealed-ci.json artefact emitted by sealed-mode CI.
/// </summary>
public string EvidencePath { get; set; } = "artifacts/sealed-mode-ci/latest/authority-sealed-ci.json";
/// <summary>
/// Maximum age accepted for the sealed evidence document.
/// </summary>
public TimeSpan MaxEvidenceAge { get; set; } = _defaultMaxEvidenceAge;
/// <summary>
/// Cache lifetime for parsed evidence to avoid re-reading the artefact on every request.
/// </summary>
public TimeSpan CacheLifetime { get; set; } = _defaultCacheLifetime;
public bool RequireAuthorityHealthPass { get; set; } = true;
public bool RequireSignerHealthPass { get; set; } = true;
public bool RequireAttestorHealthPass { get; set; } = true;
public bool RequireEgressProbePass { get; set; } = true;
internal void Validate()
{
if (!EnforcementEnabled)
{
return;
}
if (string.IsNullOrWhiteSpace(EvidencePath))
{
throw new InvalidOperationException("AirGap.SealedMode.EvidencePath must be provided when enforcement is enabled.");
}
if (MaxEvidenceAge <= TimeSpan.Zero || MaxEvidenceAge > TimeSpan.FromDays(7))
{
throw new InvalidOperationException("AirGap.SealedMode.MaxEvidenceAge must be between 00:00:01 and 7.00:00:00.");
}
if (CacheLifetime <= TimeSpan.Zero || CacheLifetime > MaxEvidenceAge)
{
throw new InvalidOperationException("AirGap.SealedMode.CacheLifetime must be greater than zero and less than or equal to AirGap.SealedMode.MaxEvidenceAge.");
}
}
}

View File

@@ -0,0 +1,28 @@
using StellaOps.Cryptography;
namespace StellaOps.Configuration;
public sealed class AuthoritySecurityOptions
{
/// <summary>
/// Rate limiting configuration applied to Authority endpoints.
/// </summary>
public AuthorityRateLimitingOptions RateLimiting { get; } = new();
/// <summary>
/// Default password hashing parameters advertised to Authority plug-ins.
/// </summary>
public PasswordHashOptions PasswordHashing { get; } = new();
/// <summary>
/// Sender-constraint configuration (DPoP, mTLS).
/// </summary>
public AuthoritySenderConstraintOptions SenderConstraints { get; } = new();
internal void Validate()
{
RateLimiting.Validate();
PasswordHashing.Validate();
SenderConstraints.Validate();
}
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Configuration;
public sealed class AuthoritySenderConstraintOptions
{
public AuthoritySenderConstraintOptions()
{
Dpop = new AuthorityDpopOptions();
Mtls = new AuthorityMtlsOptions();
}
public AuthorityDpopOptions Dpop { get; }
public AuthorityMtlsOptions Mtls { get; }
internal void Validate()
{
Dpop.Validate();
Mtls.Validate();
}
}

View File

@@ -0,0 +1,35 @@
using StellaOps.Auth.Abstractions;
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class AuthorityServiceAccountSeedOptions
{
public string AccountId { get; set; } = string.Empty;
public string Tenant { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Description { get; set; }
public bool Enabled { get; set; } = true;
public IList<string> AuthorizedClients { get; } = new List<string>();
public IList<string> AllowedScopes { get; } = new List<string>();
public IDictionary<string, IList<string>> Attributes { get; } =
new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
internal void Normalize()
{
AccountId = (AccountId ?? string.Empty).Trim();
Tenant = string.IsNullOrWhiteSpace(Tenant) ? string.Empty : Tenant.Trim().ToLowerInvariant();
DisplayName = (DisplayName ?? string.Empty).Trim();
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim();
NormalizeList(AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
NormalizeList(AllowedScopes, scope =>
{
var normalized = StellaOpsScopes.Normalize(scope);
return normalized ?? scope.Trim().ToLowerInvariant();
}, StringComparer.Ordinal);
NormalizeAttributes(Attributes);
}
}

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class AuthorityServiceAccountSeedOptions
{
private static void NormalizeList(IList<string> values, Func<string, string> normalize, IEqualityComparer<string> comparer)
{
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(normalize);
comparer ??= StringComparer.Ordinal;
if (values.Count == 0)
{
return;
}
var seen = new HashSet<string>(comparer);
for (var index = values.Count - 1; index >= 0; index--)
{
var current = values[index];
if (string.IsNullOrWhiteSpace(current))
{
values.RemoveAt(index);
continue;
}
var normalized = normalize(current);
if (string.IsNullOrWhiteSpace(normalized))
{
values.RemoveAt(index);
continue;
}
if (!seen.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
private static void NormalizeAttributes(IDictionary<string, IList<string>> attributes)
{
ArgumentNullException.ThrowIfNull(attributes);
if (attributes.Count == 0)
{
return;
}
var normalized = new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, values) in attributes)
{
var key = string.IsNullOrWhiteSpace(name) ? null : name.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var normalizedValues = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var wildcard = false;
if (values is not null)
{
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var trimmed = value.Trim();
if (trimmed.Equals("*", StringComparison.Ordinal))
{
normalizedValues.Clear();
normalizedValues.Add("*");
wildcard = true;
break;
}
var lower = trimmed.ToLowerInvariant();
if (seen.Add(lower))
{
normalizedValues.Add(lower);
}
}
}
if (wildcard || normalizedValues.Count > 0)
{
normalized[key] = normalizedValues;
}
}
attributes.Clear();
foreach (var pair in normalized)
{
attributes[pair.Key] = pair.Value;
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace StellaOps.Configuration;
public sealed partial class AuthorityServiceAccountSeedOptions
{
private static readonly Regex _accountIdRegex = new("^[a-z0-9][a-z0-9:_-]{2,63}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> _allowedAttributeKeys = new(new[]
{
"env",
"owner",
"business_tier"
}, StringComparer.OrdinalIgnoreCase);
internal void Validate(ISet<string> tenantIds)
{
if (string.IsNullOrWhiteSpace(AccountId))
{
throw new InvalidOperationException("Delegation service account seeds require an accountId.");
}
if (!_accountIdRegex.IsMatch(AccountId))
{
throw new InvalidOperationException($"Service account id '{AccountId}' must contain lowercase letters, digits, colon, underscore, or hyphen.");
}
if (string.IsNullOrWhiteSpace(Tenant))
{
throw new InvalidOperationException($"Service account '{AccountId}' requires a tenant assignment.");
}
if (tenantIds.Count > 0 && !tenantIds.Contains(Tenant))
{
throw new InvalidOperationException($"Service account '{AccountId}' references unknown tenant '{Tenant}'.");
}
if (AllowedScopes.Count == 0)
{
throw new InvalidOperationException($"Service account '{AccountId}' must specify at least one allowed scope.");
}
if (Attributes.Count > 0)
{
foreach (var attributeName in Attributes.Keys)
{
if (!_allowedAttributeKeys.Contains(attributeName))
{
throw new InvalidOperationException($"Service account '{AccountId}' defines unsupported attribute '{attributeName}'. Allowed attributes: env, owner, business_tier.");
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthorityStorageOptions
{
/// <summary>
/// Connection string used by Authority storage.
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// Optional explicit database name override.
/// </summary>
public string? DatabaseName { get; set; }
/// <summary>
/// Command timeout.
/// </summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
internal void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
{
throw new InvalidOperationException("Authority storage requires a connection string.");
}
if (CommandTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Authority storage command timeout must be greater than zero.");
}
}
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Configuration;
public sealed class AuthorityTenantAdvisoryAiOptions
{
public AdvisoryAiTenantRemoteInferenceOptions RemoteInference { get; } = new();
internal void Normalize(AuthorityAdvisoryAiOptions? _) => RemoteInference.Normalize();
internal void Validate(AuthorityAdvisoryAiOptions? globalOptions) => RemoteInference.Validate(globalOptions);
}

View File

@@ -0,0 +1,29 @@
using System;
namespace StellaOps.Configuration;
public sealed class AuthorityTenantDelegationOptions
{
public int? MaxActiveTokens { get; set; }
internal void Normalize(AuthorityDelegationOptions defaults)
{
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
}
internal void Validate(AuthorityDelegationOptions defaults, string tenantId)
{
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
if (MaxActiveTokens is { } value && value <= 0)
{
throw new InvalidOperationException($"Tenant '{tenantId}' delegation maxActiveTokens must be greater than zero when specified.");
}
}
public int ResolveMaxActiveTokens(AuthorityDelegationOptions defaults)
{
_ = defaults ?? throw new ArgumentNullException(nameof(defaults));
return MaxActiveTokens ?? defaults.Quotas.MaxActiveTokens;
}
}

View File

@@ -0,0 +1,85 @@
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class AuthorityTenantOptions
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public string IsolationMode { get; set; } = "shared";
public IList<string> DefaultRoles { get; } = new List<string>();
public IList<string> Projects { get; } = new List<string>();
public IDictionary<string, AuthorityTenantRoleOptions> Roles { get; } =
new Dictionary<string, AuthorityTenantRoleOptions>(StringComparer.OrdinalIgnoreCase);
public AuthorityTenantAdvisoryAiOptions AdvisoryAi { get; } = new();
public AuthorityTenantDelegationOptions Delegation { get; } = new();
internal void Normalize(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
{
Id = (Id ?? string.Empty).Trim();
DisplayName = (DisplayName ?? string.Empty).Trim();
Status = string.IsNullOrWhiteSpace(Status) ? "active" : Status.Trim();
IsolationMode = string.IsNullOrWhiteSpace(IsolationMode) ? "shared" : IsolationMode.Trim();
for (var index = DefaultRoles.Count - 1; index >= 0; index--)
{
var role = DefaultRoles[index];
if (string.IsNullOrWhiteSpace(role))
{
DefaultRoles.RemoveAt(index);
continue;
}
DefaultRoles[index] = role.Trim();
}
if (Projects.Count > 0)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = Projects.Count - 1; index >= 0; index--)
{
var project = Projects[index];
if (string.IsNullOrWhiteSpace(project))
{
Projects.RemoveAt(index);
continue;
}
var normalized = project.Trim().ToLowerInvariant();
if (!seen.Add(normalized))
{
Projects.RemoveAt(index);
continue;
}
Projects[index] = normalized;
}
}
AdvisoryAi.Normalize(advisoryAiOptions);
Delegation.Normalize(delegationOptions);
if (Roles.Count > 0)
{
var normalizedRoles = new Dictionary<string, AuthorityTenantRoleOptions>(StringComparer.Ordinal);
foreach (var (roleName, roleOptions) in Roles)
{
if (string.IsNullOrWhiteSpace(roleName) || roleOptions is null)
{
continue;
}
var normalizedName = roleName.Trim().ToLowerInvariant();
roleOptions.Normalize(normalizedName);
normalizedRoles[normalizedName] = roleOptions;
}
Roles.Clear();
foreach (var entry in normalizedRoles)
{
Roles.Add(entry.Key, entry.Value);
}
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Text.RegularExpressions;
namespace StellaOps.Configuration;
public sealed partial class AuthorityTenantOptions
{
private static readonly Regex _tenantSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex _projectSlugRegex = new("^[a-z0-9-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
internal void Validate(AuthorityAdvisoryAiOptions? advisoryAiOptions, AuthorityDelegationOptions delegationOptions)
{
if (string.IsNullOrWhiteSpace(Id))
{
throw new InvalidOperationException("Each tenant requires an id (slug).");
}
if (!_tenantSlugRegex.IsMatch(Id))
{
throw new InvalidOperationException($"Tenant id '{Id}' must contain only lowercase letters, digits, and hyphen.");
}
if (string.IsNullOrWhiteSpace(DisplayName))
{
DisplayName = Id;
}
if (Projects.Count > 0)
{
foreach (var project in Projects)
{
if (!_projectSlugRegex.IsMatch(project))
{
throw new InvalidOperationException($"Tenant '{Id}' defines project '{project}' which must contain only lowercase letters, digits, and hyphen.");
}
}
}
AdvisoryAi.Validate(advisoryAiOptions);
Delegation.Validate(delegationOptions, Id);
if (Roles.Count > 0)
{
foreach (var (roleName, roleOptions) in Roles)
{
if (roleOptions is null)
{
throw new InvalidOperationException($"Tenant '{Id}' defines role '{roleName}' without configuration.");
}
roleOptions.Validate(Id, roleName);
}
}
}
}

View File

@@ -0,0 +1,94 @@
using StellaOps.Auth.Abstractions;
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class AuthorityTenantRoleOptions
{
public IList<string> Scopes { get; } = new List<string>();
public IDictionary<string, IList<string>> Attributes { get; } =
new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
internal void Normalize(string roleName)
{
if (Scopes.Count > 0)
{
var seenScopes = new HashSet<string>(StringComparer.Ordinal);
for (var index = Scopes.Count - 1; index >= 0; index--)
{
var current = Scopes[index];
var normalized = StellaOpsScopes.Normalize(current);
if (string.IsNullOrWhiteSpace(normalized))
{
Scopes.RemoveAt(index);
continue;
}
if (!seenScopes.Add(normalized))
{
Scopes.RemoveAt(index);
continue;
}
Scopes[index] = normalized;
}
}
if (Attributes.Count > 0)
{
var normalizedAttributes = new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (attributeName, values) in Attributes)
{
var normalizedName = attributeName?.Trim();
if (string.IsNullOrWhiteSpace(normalizedName))
{
continue;
}
normalizedName = normalizedName.ToLowerInvariant();
var normalizedValues = new List<string>();
var seenValues = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var wildcard = false;
if (values is not null)
{
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var trimmed = value.Trim();
if (trimmed.Equals("*", StringComparison.Ordinal))
{
normalizedValues.Clear();
normalizedValues.Add("*");
wildcard = true;
break;
}
var lower = trimmed.ToLowerInvariant();
if (seenValues.Add(lower))
{
normalizedValues.Add(lower);
}
}
}
if (wildcard || normalizedValues.Count > 0)
{
normalizedAttributes[normalizedName] = normalizedValues;
}
}
Attributes.Clear();
foreach (var pair in normalizedAttributes)
{
Attributes[pair.Key] = pair.Value;
}
}
}
}

View File

@@ -0,0 +1,42 @@
using StellaOps.Auth.Abstractions;
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class AuthorityTenantRoleOptions
{
private static readonly HashSet<string> _allowedAttributeKeys = new(new[]
{
"env",
"owner",
"business_tier"
}, StringComparer.OrdinalIgnoreCase);
internal void Validate(string tenantId, string roleName)
{
if (Scopes.Count == 0)
{
throw new InvalidOperationException($"Tenant '{tenantId}' role '{roleName}' must specify at least one scope.");
}
foreach (var scope in Scopes)
{
if (!StellaOpsScopes.IsKnown(scope))
{
throw new InvalidOperationException($"Tenant '{tenantId}' role '{roleName}' references unknown scope '{scope}'.");
}
}
if (Attributes.Count > 0)
{
foreach (var attributeName in Attributes.Keys)
{
if (!_allowedAttributeKeys.Contains(attributeName))
{
throw new InvalidOperationException($"Tenant '{tenantId}' role '{roleName}' defines unsupported attribute '{attributeName}'. Allowed attributes: env, owner, business_tier.");
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
using System;
namespace StellaOps.Configuration;
/// <summary>
/// Anti-forgery token configuration used to protect workflow submissions.
/// </summary>
public sealed class AuthorityVulnAntiForgeryOptions
{
/// <summary>
/// Determines whether anti-forgery token issuance/verification is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Audience claim value embedded in issued tokens.
/// </summary>
public string Audience { get; set; } = "stellaops:vuln-workflow";
/// <summary>
/// Default lifetime applied when callers omit an explicit expiration.
/// </summary>
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Maximum lifetime permitted for anti-forgery tokens.
/// </summary>
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Optional maximum size for the context dictionary payload.
/// </summary>
public int MaxContextEntries { get; set; } = 16;
/// <summary>
/// Maximum length permitted per context value entry.
/// </summary>
public int MaxContextValueLength { get; set; } = 256;
internal void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Audience))
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.audience must be specified when anti-forgery tokens are enabled.");
}
if (DefaultLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.defaultLifetime must be greater than zero.");
}
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
}
if (MaxContextEntries < 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxContextEntries must be non-negative.");
}
if (MaxContextValueLength <= 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxContextValueLength must be greater than zero.");
}
}
}

View File

@@ -0,0 +1,72 @@
using System;
namespace StellaOps.Configuration;
/// <summary>
/// Attachment token configuration used to protect ledger attachments.
/// </summary>
public sealed class AuthorityVulnAttachmentOptions
{
/// <summary>
/// Determines whether attachment token issuance/verification is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Default lifetime for attachment access tokens.
/// </summary>
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Maximum lifetime permitted for attachment access tokens.
/// </summary>
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Payload type identifier emitted in audit and downstream validation.
/// </summary>
public string PayloadType { get; set; } = "application/vnd.stellaops.vuln-attachment-token+json";
/// <summary>
/// Optional limit on attachment metadata entries.
/// </summary>
public int MaxMetadataEntries { get; set; } = 16;
/// <summary>
/// Optional maximum length for metadata values.
/// </summary>
public int MaxMetadataValueLength { get; set; } = 512;
internal void Validate()
{
if (!Enabled)
{
return;
}
if (DefaultLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.defaultLifetime must be greater than zero when attachment tokens are enabled.");
}
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
}
if (string.IsNullOrWhiteSpace(PayloadType))
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.payloadType must be specified when attachment tokens are enabled.");
}
if (MaxMetadataEntries < 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxMetadataEntries must be non-negative.");
}
if (MaxMetadataValueLength <= 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxMetadataValueLength must be greater than zero.");
}
}
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Configuration;
/// <summary>
/// Workflow specific configuration for Vuln Explorer clients.
/// </summary>
public sealed class AuthorityVulnWorkflowOptions
{
/// <summary>
/// Anti-forgery token configuration.
/// </summary>
public AuthorityVulnAntiForgeryOptions AntiForgery { get; } = new();
internal void Validate()
{
AntiForgery.Validate();
}
}

View File

@@ -1,6 +1,3 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <summary>
@@ -24,157 +21,3 @@ public sealed class AuthorityVulnerabilityExplorerOptions
Attachments.Validate();
}
}
/// <summary>
/// Workflow specific configuration for Vuln Explorer clients.
/// </summary>
public sealed class AuthorityVulnWorkflowOptions
{
/// <summary>
/// Anti-forgery token configuration.
/// </summary>
public AuthorityVulnAntiForgeryOptions AntiForgery { get; } = new();
internal void Validate()
{
AntiForgery.Validate();
}
}
/// <summary>
/// Anti-forgery token configuration used to protect workflow submissions.
/// </summary>
public sealed class AuthorityVulnAntiForgeryOptions
{
/// <summary>
/// Determines whether anti-forgery token issuance/verification is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Audience claim value embedded in issued tokens.
/// </summary>
public string Audience { get; set; } = "stellaops:vuln-workflow";
/// <summary>
/// Default lifetime applied when callers omit an explicit expiration.
/// </summary>
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Maximum lifetime permitted for anti-forgery tokens.
/// </summary>
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Optional maximum size for the context dictionary payload.
/// </summary>
public int MaxContextEntries { get; set; } = 16;
/// <summary>
/// Maximum length permitted per context value entry.
/// </summary>
public int MaxContextValueLength { get; set; } = 256;
internal void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Audience))
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.audience must be specified when anti-forgery tokens are enabled.");
}
if (DefaultLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.defaultLifetime must be greater than zero.");
}
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
}
if (MaxContextEntries < 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxContextEntries must be non-negative.");
}
if (MaxContextValueLength <= 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.workflow.antiForgery.maxContextValueLength must be greater than zero.");
}
}
}
/// <summary>
/// Attachment token configuration used to protect ledger attachments.
/// </summary>
public sealed class AuthorityVulnAttachmentOptions
{
/// <summary>
/// Determines whether attachment token issuance/verification is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Default lifetime for attachment access tokens.
/// </summary>
public TimeSpan DefaultLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Maximum lifetime permitted for attachment access tokens.
/// </summary>
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Payload type identifier emitted in audit and downstream validation.
/// </summary>
public string PayloadType { get; set; } = "application/vnd.stellaops.vuln-attachment-token+json";
/// <summary>
/// Optional limit on attachment metadata entries.
/// </summary>
public int MaxMetadataEntries { get; set; } = 16;
/// <summary>
/// Optional maximum length for metadata values.
/// </summary>
public int MaxMetadataValueLength { get; set; } = 512;
internal void Validate()
{
if (!Enabled)
{
return;
}
if (DefaultLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.defaultLifetime must be greater than zero when attachment tokens are enabled.");
}
if (MaxLifetime <= TimeSpan.Zero || MaxLifetime < DefaultLifetime)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxLifetime must be greater than zero and greater than or equal to defaultLifetime.");
}
if (string.IsNullOrWhiteSpace(PayloadType))
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.payloadType must be specified when attachment tokens are enabled.");
}
if (MaxMetadataEntries < 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxMetadataEntries must be non-negative.");
}
if (MaxMetadataValueLength <= 0)
{
throw new InvalidOperationException("vulnerabilityExplorer.attachments.maxMetadataValueLength must be greater than zero.");
}
}
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <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();
}
}
}

View File

@@ -19,7 +19,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />

View File

@@ -8,7 +8,7 @@ namespace StellaOps.Configuration;
/// </summary>
public static class StellaOpsAuthorityConfiguration
{
private static readonly string[] DefaultAuthorityYamlFiles =
private static readonly string[] _defaultAuthorityYamlFiles =
{
"authority.yaml",
"authority.local.yaml",
@@ -43,7 +43,7 @@ public static class StellaOpsAuthorityConfiguration
private static void AppendDefaultYamlFiles(StellaOpsBootstrapOptions<StellaOpsAuthorityOptions> options)
{
foreach (var path in DefaultAuthorityYamlFiles)
foreach (var path in _defaultAuthorityYamlFiles)
{
var alreadyPresent = options.YamlFiles.Any(file =>
string.Equals(file.Path, path, StringComparison.OrdinalIgnoreCase));

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
/// <summary>
/// Strongly typed configuration for the StellaOps Authority service.
/// </summary>
public sealed partial class StellaOpsAuthorityOptions
{
private readonly List<string> _pluginDirectories = new();
private readonly List<string> _bypassNetworks = new();
private readonly List<AuthorityTenantOptions> _tenants = new();
/// <summary>
/// Schema version for downstream consumers to coordinate breaking changes.
/// </summary>
public int SchemaVersion { get; set; } = 1;
/// <summary>
/// Absolute issuer URI advertised to clients (e.g. https://authority.stella-ops.local).
/// </summary>
public Uri? Issuer { get; set; }
/// <summary>
/// Lifetime for OAuth access tokens issued by Authority.
/// </summary>
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Lifetime for OAuth refresh tokens issued by Authority.
/// </summary>
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Lifetime for OpenID Connect identity tokens.
/// </summary>
public TimeSpan IdentityTokenLifetime { get; set; } = TimeSpan.FromMinutes(55);
/// <summary>
/// Lifetime for OAuth authorization codes.
/// </summary>
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Lifetime for OAuth device codes (device authorization flow).
/// </summary>
public TimeSpan DeviceCodeLifetime { get; set; } = TimeSpan.FromMinutes(15);
/// <summary>
/// Directories scanned for Authority plugins (absolute or relative to application base path).
/// </summary>
public IList<string> PluginDirectories => _pluginDirectories;
/// <summary>
/// CIDR blocks permitted to bypass certain authentication policies (e.g. on-host cron).
/// </summary>
public IList<string> BypassNetworks => _bypassNetworks;
/// <summary>
/// Declared tenants for the deployment.
/// </summary>
public IList<AuthorityTenantOptions> Tenants => _tenants;
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Configuration;
public sealed partial class StellaOpsAuthorityOptions
{
/// <summary>
/// Advisory AI configuration (remote inference policies, consent defaults).
/// </summary>
public AuthorityAdvisoryAiOptions AdvisoryAi { get; } = new();
/// <summary>
/// Notification system configuration (webhook allowlists, ack token policies).
/// </summary>
public AuthorityNotificationsOptions Notifications { get; } = new();
/// <summary>
/// Air-gap/sealed mode configuration for Authority.
/// </summary>
public AuthorityAirGapOptions AirGap { get; } = new();
/// <summary>
/// Vulnerability explorer integration configuration (workflow CSRF tokens, attachments).
/// </summary>
public AuthorityVulnerabilityExplorerOptions VulnerabilityExplorer { get; } = new();
/// <summary>
/// Exception governance configuration (routing templates, MFA requirements).
/// </summary>
public AuthorityExceptionsOptions Exceptions { get; } = new();
/// <summary>
/// API lifecycle configuration (deprecations, migration messaging).
/// </summary>
public AuthorityApiLifecycleOptions ApiLifecycle { get; } = new();
/// <summary>
/// Signing options for Authority-generated artefacts (revocation bundles, JWKS).
/// </summary>
public AuthoritySigningOptions Signing { get; } = new();
/// <summary>
/// Delegation and service account configuration.
/// </summary>
public AuthorityDelegationOptions Delegation { get; } = new();
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class StellaOpsAuthorityOptions
{
private static void ValidateLifetime(TimeSpan value, string propertyName, TimeSpan maximum)
{
if (value <= TimeSpan.Zero)
{
throw new InvalidOperationException($"Authority configuration requires {propertyName} to be greater than zero.");
}
if (value > maximum)
{
throw new InvalidOperationException($"Authority configuration requires {propertyName} to be less than or equal to {maximum}.");
}
}
private static void NormaliseList(IList<string> values)
{
if (values.Count == 0)
{
return;
}
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var entry = values[index];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(index);
continue;
}
var trimmed = entry.Trim();
if (!unique.Add(trimmed))
{
values.RemoveAt(index);
continue;
}
values[index] = trimmed;
}
}
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Configuration;
public sealed partial class StellaOpsAuthorityOptions
{
/// <summary>
/// Configuration describing the Authority storage layer.
/// </summary>
public AuthorityStorageOptions Storage { get; } = new();
/// <summary>
/// Bootstrap settings for initial administrative provisioning.
/// </summary>
public AuthorityBootstrapOptions Bootstrap { get; } = new();
/// <summary>
/// Configuration describing available Authority plugins and their manifests.
/// </summary>
public AuthorityPluginSettings Plugins { get; } = new();
/// <summary>
/// Sovereign cryptography configuration (provider registry + plugins).
/// </summary>
public StellaOpsCryptoOptions Crypto { get; } = new();
/// <summary>
/// Security-related configuration for the Authority host.
/// </summary>
public AuthoritySecurityOptions Security { get; } = new();
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Configuration;
public sealed partial class StellaOpsAuthorityOptions
{
/// <summary>
/// Validates configured values and normalises collections.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid.</exception>
public void Validate()
{
if (SchemaVersion <= 0)
{
throw new InvalidOperationException("Authority configuration requires a positive schemaVersion.");
}
if (Issuer is null)
{
throw new InvalidOperationException("Authority configuration requires an issuer URL.");
}
if (!Issuer.IsAbsoluteUri)
{
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
}
if (string.Equals(Issuer.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !Issuer.IsLoopback)
{
throw new InvalidOperationException("Authority issuer must use HTTPS unless running on a loopback interface.");
}
ValidateLifetime(AccessTokenLifetime, nameof(AccessTokenLifetime), TimeSpan.FromHours(24));
ValidateLifetime(RefreshTokenLifetime, nameof(RefreshTokenLifetime), TimeSpan.FromDays(365));
ValidateLifetime(IdentityTokenLifetime, nameof(IdentityTokenLifetime), TimeSpan.FromHours(24));
ValidateLifetime(AuthorizationCodeLifetime, nameof(AuthorizationCodeLifetime), TimeSpan.FromHours(1));
ValidateLifetime(DeviceCodeLifetime, nameof(DeviceCodeLifetime), TimeSpan.FromHours(24));
NormaliseList(_pluginDirectories);
NormaliseList(_bypassNetworks);
Security.Validate();
AdvisoryAi.Normalize();
AdvisoryAi.Validate();
Notifications.Validate();
AirGap.Validate();
VulnerabilityExplorer.Validate();
ApiLifecycle.Validate();
Signing.Validate();
Delegation.NormalizeAndValidate(_tenants);
Plugins.NormalizeAndValidate();
Storage.Validate();
Exceptions.Validate();
Bootstrap.Validate();
if (_tenants.Count > 0)
{
var identifiers = new HashSet<string>(StringComparer.Ordinal);
foreach (var tenant in _tenants)
{
tenant.Normalize(AdvisoryAi, Delegation);
tenant.Validate(AdvisoryAi, Delegation);
if (!identifiers.Add(tenant.Id))
{
throw new InvalidOperationException($"Authority configuration contains duplicate tenant identifier '{tenant.Id}'.");
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.Configuration;
using NetEscapades.Configuration.Yaml;
using System;
namespace StellaOps.Configuration;
public static partial class StellaOpsConfigurationBootstrapper
{
public static IConfigurationBuilder AddStellaOpsDefaults(
this IConfigurationBuilder builder,
Action<StellaOpsConfigurationOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
var options = new StellaOpsConfigurationOptions();
configure?.Invoke(options);
if (!string.IsNullOrWhiteSpace(options.BasePath))
{
builder.SetBasePath(options.BasePath!);
}
if (options.IncludeJsonFiles)
{
foreach (var file in options.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (options.IncludeYamlFiles)
{
foreach (var file in options.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
options.ConfigureBuilder?.Invoke(builder);
if (options.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
}
return builder;
}
}

View File

@@ -5,7 +5,7 @@ using System;
namespace StellaOps.Configuration;
public static class StellaOpsConfigurationBootstrapper
public static partial class StellaOpsConfigurationBootstrapper
{
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
@@ -65,43 +65,4 @@ public static class StellaOpsConfigurationBootstrapper
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
}
public static IConfigurationBuilder AddStellaOpsDefaults(
this IConfigurationBuilder builder,
Action<StellaOpsConfigurationOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
var options = new StellaOpsConfigurationOptions();
configure?.Invoke(options);
if (!string.IsNullOrWhiteSpace(options.BasePath))
{
builder.SetBasePath(options.BasePath!);
}
if (options.IncludeJsonFiles)
{
foreach (var file in options.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (options.IncludeYamlFiles)
{
foreach (var file in options.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
options.ConfigureBuilder?.Invoke(builder);
if (options.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
}
return builder;
}
}

View File

@@ -0,0 +1,67 @@
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
using System;
#if STELLAOPS_CRYPTO_PRO
using StellaOps.Cryptography.Plugin.CryptoPro;
#endif
namespace StellaOps.Configuration;
public static partial class StellaOpsCryptoServiceCollectionExtensions
{
private static void ApplyRegistry(
CryptoProviderRegistryOptions target,
CryptoProviderRegistryOptions source)
{
target.ActiveProfile = source.ActiveProfile;
target.PreferredProviders.Clear();
foreach (var provider in source.PreferredProviders)
{
if (!string.IsNullOrWhiteSpace(provider))
{
target.PreferredProviders.Add(provider.Trim());
}
}
target.Profiles.Clear();
foreach (var kvp in source.Profiles)
{
if (kvp.Value is null)
{
continue;
}
var profile = new CryptoProviderProfileOptions();
foreach (var provider in kvp.Value.PreferredProviders)
{
if (!string.IsNullOrWhiteSpace(provider))
{
profile.PreferredProviders.Add(provider.Trim());
}
}
target.Profiles[kvp.Key] = profile;
}
}
private static void CopyPkcs11Options(Pkcs11GostProviderOptions target, Pkcs11GostProviderOptions source)
{
target.Keys.Clear();
foreach (var key in source.Keys)
{
target.Keys.Add(key.Clone());
}
}
#if STELLAOPS_CRYPTO_PRO
private static void CopyCryptoProOptions(CryptoProGostProviderOptions target, CryptoProGostProviderOptions source)
{
target.Keys.Clear();
foreach (var key in source.Keys)
{
target.Keys.Add(key.Clone());
}
}
#endif
}

View File

@@ -11,7 +11,7 @@ using System;
namespace StellaOps.Configuration;
public static class StellaOpsCryptoServiceCollectionExtensions
public static partial class StellaOpsCryptoServiceCollectionExtensions
{
public static IServiceCollection AddStellaOpsCrypto(
this IServiceCollection services,
@@ -50,58 +50,4 @@ public static class StellaOpsCryptoServiceCollectionExtensions
return services;
}
private static void ApplyRegistry(
CryptoProviderRegistryOptions target,
CryptoProviderRegistryOptions source)
{
target.ActiveProfile = source.ActiveProfile;
target.PreferredProviders.Clear();
foreach (var provider in source.PreferredProviders)
{
if (!string.IsNullOrWhiteSpace(provider))
{
target.PreferredProviders.Add(provider.Trim());
}
}
target.Profiles.Clear();
foreach (var kvp in source.Profiles)
{
if (kvp.Value is null)
{
continue;
}
var profile = new CryptoProviderProfileOptions();
foreach (var provider in kvp.Value.PreferredProviders)
{
if (!string.IsNullOrWhiteSpace(provider))
{
profile.PreferredProviders.Add(provider.Trim());
}
}
target.Profiles[kvp.Key] = profile;
}
}
private static void CopyPkcs11Options(Pkcs11GostProviderOptions target, Pkcs11GostProviderOptions source)
{
target.Keys.Clear();
foreach (var key in source.Keys)
{
target.Keys.Add(key.Clone());
}
}
#if STELLAOPS_CRYPTO_PRO
private static void CopyCryptoProOptions(CryptoProGostProviderOptions target, CryptoProGostProviderOptions source)
{
target.Keys.Clear();
foreach (var key in source.Keys)
{
target.Keys.Add(key.Clone());
}
}
#endif
}

View File

@@ -11,5 +11,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-20260130-002-S0 | DONE | Stage 0 Tier 0 remediation for AuthorityPluginConfigurationAnalyzer.cs (using sort + re-audit). |
| REMED-20260130-002-S1 | DONE | Stage 1 Tier 0 remediation batch (5 files) logged in remediation-log-20260130-114501.csv. |
| REMED-20260130-002-S2 | DONE | Stage 2 Tier 0 remediation for StellaOps.Configuration; build/test pass; re-audit delta recorded. |
| REMED-20260130-002-SOLID-01 | DOING | SOLID review notes added for Authority/Configuration option files; remaining project files pending. |
| REMED-20260130-002-SOLID-01 | DONE | SOLID review notes refreshed for Authority/Configuration option files after refactor. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-20260203-01 | DONE | Authority options refactor and file split completed; dotnet test passed 2026-02-03 (25 tests). |