1576 lines
51 KiB
C#
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 <pluginName>.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);
|
|
}
|
|
}
|