using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.RateLimiting; using StellaOps.Auth.Abstractions; using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Cryptography; namespace StellaOps.Configuration; /// /// Strongly typed configuration for the StellaOps Authority service. /// public sealed class StellaOpsAuthorityOptions { private readonly List pluginDirectories = new(); private readonly List bypassNetworks = new(); private readonly List tenants = new(); /// /// Schema version for downstream consumers to coordinate breaking changes. /// public int SchemaVersion { get; set; } = 1; /// /// Absolute issuer URI advertised to clients (e.g. https://authority.stella-ops.local). /// public Uri? Issuer { get; set; } /// /// Lifetime for OAuth access tokens issued by Authority. /// public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(2); /// /// Lifetime for OAuth refresh tokens issued by Authority. /// public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30); /// /// Lifetime for OpenID Connect identity tokens. /// public TimeSpan IdentityTokenLifetime { get; set; } = TimeSpan.FromMinutes(5); /// /// Lifetime for OAuth authorization codes. /// public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5); /// /// Lifetime for OAuth device codes (device authorization flow). /// public TimeSpan DeviceCodeLifetime { get; set; } = TimeSpan.FromMinutes(15); /// /// Directories scanned for Authority plugins (absolute or relative to application base path). /// public IList PluginDirectories => pluginDirectories; /// /// CIDR blocks permitted to bypass certain authentication policies (e.g. on-host cron). /// public IList BypassNetworks => bypassNetworks; /// /// Declared tenants for the deployment. /// public IList Tenants => tenants; /// /// Configuration describing the Authority storage layer. /// public AuthorityStorageOptions Storage { get; } = new(); /// /// Bootstrap settings for initial administrative provisioning. /// public AuthorityBootstrapOptions Bootstrap { get; } = new(); /// /// Configuration describing available Authority plugins and their manifests. /// public AuthorityPluginSettings Plugins { get; } = new(); /// /// Sovereign cryptography configuration (provider registry + plugins). /// public StellaOpsCryptoOptions Crypto { get; } = new(); /// /// Security-related configuration for the Authority host. /// public AuthoritySecurityOptions Security { get; } = new(); /// /// Advisory AI configuration (remote inference policies, consent defaults). /// public AuthorityAdvisoryAiOptions AdvisoryAi { get; } = new(); /// /// Notification system configuration (webhook allowlists, ack token policies). /// public AuthorityNotificationsOptions Notifications { get; } = new(); /// /// Air-gap/sealed mode configuration for Authority. /// public AuthorityAirGapOptions AirGap { get; } = new(); /// /// Vulnerability explorer integration configuration (workflow CSRF tokens, attachments). /// public AuthorityVulnerabilityExplorerOptions VulnerabilityExplorer { get; } = new(); /// /// Exception governance configuration (routing templates, MFA requirements). /// public AuthorityExceptionsOptions Exceptions { get; } = new(); /// /// API lifecycle configuration (deprecations, migration messaging). /// public AuthorityApiLifecycleOptions ApiLifecycle { get; } = new(); /// /// Signing options for Authority-generated artefacts (revocation bundles, JWKS). /// public AuthoritySigningOptions Signing { get; } = new(); /// /// Delegation and service account configuration. /// public AuthorityDelegationOptions Delegation { get; } = new(); /// /// Validates configured values and normalises collections. /// /// Thrown when configuration is invalid. 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(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}'."); } } } } 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 values) { if (values.Count == 0) { return; } var unique = new HashSet(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; } } } public sealed class AuthorityAirGapOptions { public AuthoritySealedModeOptions SealedMode { get; } = new(); internal void Validate() { SealedMode.Validate(); } } public sealed class AuthoritySealedModeOptions { private static readonly TimeSpan DefaultMaxEvidenceAge = TimeSpan.FromHours(6); private static readonly TimeSpan DefaultCacheLifetime = TimeSpan.FromMinutes(1); /// /// Enables sealed-mode enforcement for clients that declare the requirement. /// public bool EnforcementEnabled { get; set; } /// /// Path to the latest authority-sealed-ci.json artefact emitted by sealed-mode CI. /// public string EvidencePath { get; set; } = "artifacts/sealed-mode-ci/latest/authority-sealed-ci.json"; /// /// Maximum age accepted for the sealed evidence document. /// public TimeSpan MaxEvidenceAge { get; set; } = DefaultMaxEvidenceAge; /// /// Cache lifetime for parsed evidence to avoid re-reading the artefact on every request. /// 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."); } } } public sealed class AuthoritySecurityOptions { /// /// Rate limiting configuration applied to Authority endpoints. /// public AuthorityRateLimitingOptions RateLimiting { get; } = new(); /// /// Default password hashing parameters advertised to Authority plug-ins. /// public PasswordHashOptions PasswordHashing { get; } = new(); /// /// Sender-constraint configuration (DPoP, mTLS). /// public AuthoritySenderConstraintOptions SenderConstraints { get; } = new(); internal void Validate() { RateLimiting.Validate(); PasswordHashing.Validate(); SenderConstraints.Validate(); } } 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 }; } /// /// Rate limiting configuration applied to the /token endpoint. /// public AuthorityEndpointRateLimitOptions Token { get; } /// /// Rate limiting configuration applied to the /authorize endpoint. /// public AuthorityEndpointRateLimitOptions Authorize { get; } /// /// Rate limiting configuration applied to /internal endpoints. /// public AuthorityEndpointRateLimitOptions Internal { get; } internal void Validate() { Token.Validate(nameof(Token)); Authorize.Validate(nameof(Authorize)); Internal.Validate(nameof(Internal)); } } 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(); } } public sealed class AuthorityDpopOptions { private readonly HashSet allowedAlgorithms = new(StringComparer.OrdinalIgnoreCase) { "ES256", "ES384" }; public bool Enabled { get; set; } /// /// Allows temporarily bypassing DPoP enforcement (for emergency drills only). /// 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 AllowedAlgorithms => allowedAlgorithms; public IReadOnlySet NormalizedAlgorithms { get; private set; } = new HashSet(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(); } } public sealed class AuthorityDpopNonceOptions { private readonly HashSet 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 RequiredAudiences => requiredAudiences; public IReadOnlySet NormalizedAudiences { get; private set; } = new HashSet(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; } } public sealed class AuthorityMtlsOptions { private readonly HashSet enforceForAudiences = new(StringComparer.OrdinalIgnoreCase) { "signer" }; private readonly HashSet 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 EnforceForAudiences => enforceForAudiences; public IReadOnlySet NormalizedAudiences { get; private set; } = new HashSet(StringComparer.OrdinalIgnoreCase); public IList AllowedCertificateAuthorities { get; } = new List(); public IList AllowedSubjectPatterns { get; } = new List(); public ISet AllowedSanTypes => allowedSanTypes; public IReadOnlyList NormalizedSubjectPatterns { get; private set; } = Array.Empty(); public IReadOnlySet NormalizedSanTypes { get; private set; } = new HashSet(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(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; } } public sealed class AuthorityEndpointRateLimitOptions { /// /// Gets or sets a value indicating whether rate limiting is enabled for the endpoint. /// public bool Enabled { get; set; } = true; /// /// Maximum number of requests allowed within the configured window. /// public int PermitLimit { get; set; } = 60; /// /// Size of the fixed window applied to the rate limiter. /// public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1); /// /// Maximum number of queued requests awaiting permits. /// public int QueueLimit { get; set; } = 0; /// /// Ordering strategy for queued requests. /// 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."); } } } public sealed class AuthorityStorageOptions { /// /// Connection string used by Authority storage. /// public string ConnectionString { get; set; } = string.Empty; /// /// Optional explicit database name override. /// public string? DatabaseName { get; set; } /// /// Command timeout. /// 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."); } } } public sealed class AuthorityExceptionsOptions { private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase; private readonly List routingTemplates = new(); /// /// Declarative routing templates used to coordinate exception approvals. /// public IList RoutingTemplates => routingTemplates; /// /// Normalized lookup of routing templates keyed by template identifier. /// public IReadOnlyDictionary NormalizedRoutingTemplates { get; private set; } = new Dictionary(OrdinalIgnoreCase); /// /// Indicates whether any exception approval routes require MFA participation. /// public bool RequiresMfaForApprovals => routingTemplates.Any(template => template is { RequireMfa: true }); internal void Validate() { if (routingTemplates.Count == 0) { NormalizedRoutingTemplates = new Dictionary(OrdinalIgnoreCase); return; } var normalized = new Dictionary(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; } } public sealed class AuthorityExceptionRoutingTemplateOptions { /// /// Stable identifier referenced by policy packs. /// public string? Id { get; set; } /// /// Authority approval route identifier used by downstream services. /// public string? AuthorityRouteId { get; set; } /// /// Indicates whether the approval route enforces multi-factor authentication. /// public bool RequireMfa { get; set; } /// /// Optional human-readable description for operators. /// 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); public sealed class AuthorityBootstrapOptions { /// /// Enables or disables bootstrap administrative APIs. /// public bool Enabled { get; set; } = false; /// /// API key required when invoking bootstrap endpoints. /// public string? ApiKey { get; set; } = string.Empty; /// /// Default identity provider used when none is specified in bootstrap requests. /// 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."); } } } public sealed 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 DefaultRoles { get; } = new List(); public IList Projects { get; } = new List(); public IDictionary Roles { get; } = new Dictionary(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(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(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); } } } 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); } } } 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); } public sealed class AuthorityTenantRoleOptions { public IList Scopes { get; } = new List(); public IDictionary> Attributes { get; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); internal void Normalize(string roleName) { if (Scopes.Count > 0) { var seenScopes = new HashSet(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>(StringComparer.OrdinalIgnoreCase); foreach (var (attributeName, values) in Attributes) { var normalizedName = attributeName?.Trim(); if (string.IsNullOrWhiteSpace(normalizedName)) { continue; } normalizedName = normalizedName.ToLowerInvariant(); var normalizedValues = new List(); var seenValues = new HashSet(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; } } } 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."); } } } } private static readonly HashSet AllowedAttributeKeys = new(new[] { "env", "owner", "business_tier" }, StringComparer.OrdinalIgnoreCase); } public sealed class AuthorityDelegationOptions { private readonly IList serviceAccounts = new List(); private readonly Dictionary tenantOverrides = new(StringComparer.OrdinalIgnoreCase); public AuthorityDelegationQuotaOptions Quotas { get; } = new(); public IList ServiceAccounts => (IList)serviceAccounts; internal void NormalizeAndValidate(IList 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(StringComparer.OrdinalIgnoreCase); var seenAccounts = new HashSet(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; } } 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."); } } } 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; } } public sealed class AuthorityServiceAccountSeedOptions { private static readonly Regex AccountIdRegex = new("^[a-z0-9][a-z0-9:_-]{2,63}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); 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 AuthorizedClients { get; } = new List(); public IList AllowedScopes { get; } = new List(); public IDictionary> Attributes { get; } = new Dictionary>(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); } internal void Validate(ISet 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."); } } } } private static void NormalizeList(IList values, Func normalize, IEqualityComparer comparer) { ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(normalize); comparer ??= StringComparer.Ordinal; if (values.Count == 0) { return; } var seen = new HashSet(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> attributes) { ArgumentNullException.ThrowIfNull(attributes); if (attributes.Count == 0) { return; } var normalized = new Dictionary>(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(); var seen = new HashSet(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; } } private static readonly HashSet AllowedAttributeKeys = new(new[] { "env", "owner", "business_tier" }, StringComparer.OrdinalIgnoreCase); } public sealed class AuthorityPluginSettings { private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase; /// /// Directory containing per-plugin configuration manifests (relative paths resolved against application base path). /// public string ConfigurationDirectory { get; set; } = "../etc/authority.plugins"; /// /// Declarative descriptors for Authority plugins (keyed by logical plugin name). /// public IDictionary Descriptors { get; } = new Dictionary(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); } } } public sealed class AuthorityPluginDescriptorOptions { private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase; private readonly List capabilities = new(); private readonly Dictionary metadata = new(OrdinalIgnoreCase); private static readonly HashSet AllowedCapabilities = new( new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Mfa, AuthorityPluginCapabilities.ClientProvisioning, AuthorityPluginCapabilities.Bootstrap }, OrdinalIgnoreCase); /// /// Logical type identifier for the plugin (e.g. standard, ldap). /// public string? Type { get; set; } /// /// Name of the plugin assembly (without file extension). /// public string? AssemblyName { get; set; } /// /// Optional explicit assembly path override; relative paths resolve against plugin directories. /// public string? AssemblyPath { get; set; } /// /// Indicates whether the plugin should be enabled. /// public bool Enabled { get; set; } = true; /// /// Plugin capability hints surfaced to the Authority host. /// public IList Capabilities => capabilities; /// /// Optional metadata (string key/value) passed to plugin implementations. /// public IDictionary Metadata => metadata; /// /// Relative path to the plugin-specific configuration file (defaults to <pluginName>.yaml). /// 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(OrdinalIgnoreCase); var unique = new List(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); } } 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."); } } } internal AuthorityPluginManifest ToManifest(string name, string configPath) { var capabilitiesSnapshot = capabilities.Count == 0 ? Array.Empty() : capabilities.ToArray(); var metadataSnapshot = metadata.Count == 0 ? new Dictionary(OrdinalIgnoreCase) : new Dictionary(metadata, OrdinalIgnoreCase); return new AuthorityPluginManifest( name, Type ?? name, Enabled, AssemblyName, AssemblyPath, capabilitiesSnapshot, metadataSnapshot, configPath); } }