Files
git.stella-ops.org/src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs
StellaOps Bot 999e26a48e up
2025-12-13 02:22:15 +02:00

1576 lines
51 KiB
C#

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;
/// <summary>
/// Strongly typed configuration for the StellaOps Authority service.
/// </summary>
public sealed 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(5);
/// <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;
/// <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();
/// <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();
/// <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}'.");
}
}
}
}
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;
}
}
}
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);
/// <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.");
}
}
}
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();
}
}
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));
}
}
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<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();
}
}
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;
}
}
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;
}
}
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.");
}
}
}
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.");
}
}
}
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;
}
}
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);
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.");
}
}
}
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<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);
}
}
}
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<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;
}
}
}
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<string> AllowedAttributeKeys = new(new[]
{
"env",
"owner",
"business_tier"
}, StringComparer.OrdinalIgnoreCase);
}
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;
}
}
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<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);
}
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.");
}
}
}
}
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;
}
}
private static readonly HashSet<string> AllowedAttributeKeys = new(new[]
{
"env",
"owner",
"business_tier"
}, StringComparer.OrdinalIgnoreCase);
}
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);
}
}
}
public sealed class AuthorityPluginDescriptorOptions
{
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly List<string> capabilities = new();
private readonly Dictionary<string, string?> metadata = new(OrdinalIgnoreCase);
private static readonly HashSet<string> AllowedCapabilities = new(
new[]
{
AuthorityPluginCapabilities.Password,
AuthorityPluginCapabilities.Mfa,
AuthorityPluginCapabilities.ClientProvisioning,
AuthorityPluginCapabilities.Bootstrap
},
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);
}
}
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<string>()
: capabilities.ToArray();
var metadataSnapshot = metadata.Count == 0
? new Dictionary<string, string?>(OrdinalIgnoreCase)
: new Dictionary<string, string?>(metadata, OrdinalIgnoreCase);
return new AuthorityPluginManifest(
name,
Type ?? name,
Enabled,
AssemblyName,
AssemblyPath,
capabilitiesSnapshot,
metadataSnapshot,
configPath);
}
}