using System; using System.Collections.Generic; using System.Collections.ObjectModel; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; namespace StellaOps.Policy.Gateway.Options; /// /// Root configuration for the Policy Gateway host. /// public sealed class PolicyGatewayOptions { public const string SectionName = "PolicyGateway"; public PolicyGatewayTelemetryOptions Telemetry { get; } = new(); public PolicyGatewayResourceServerOptions ResourceServer { get; } = new(); public PolicyGatewayPolicyEngineOptions PolicyEngine { get; } = new(); public void Validate() { Telemetry.Validate(); ResourceServer.Validate(); PolicyEngine.Validate(); } } /// /// Logging and telemetry configuration for the gateway. /// public sealed class PolicyGatewayTelemetryOptions { public LogLevel MinimumLogLevel { get; set; } = LogLevel.Information; public void Validate() { if (!Enum.IsDefined(typeof(LogLevel), MinimumLogLevel)) { throw new InvalidOperationException("Unsupported log level configured for Policy Gateway telemetry."); } } } /// /// JWT resource server configuration for incoming requests handled by the gateway. /// public sealed class PolicyGatewayResourceServerOptions { public string Authority { get; set; } = "https://authority.stella-ops.local"; public string? MetadataAddress { get; set; } = "https://authority.stella-ops.local/.well-known/openid-configuration"; public IList Audiences { get; } = new List { "api://policy-gateway" }; public IList RequiredScopes { get; } = new List { StellaOpsScopes.PolicyRead, StellaOpsScopes.PolicyAuthor, StellaOpsScopes.PolicyReview, StellaOpsScopes.PolicyApprove, StellaOpsScopes.PolicyOperate, StellaOpsScopes.PolicySimulate, StellaOpsScopes.PolicyRun, StellaOpsScopes.PolicyActivate }; public IList RequiredTenants { get; } = new List(); public IList BypassNetworks { get; } = new List { "127.0.0.1/32", "::1/128" }; public bool RequireHttpsMetadata { get; set; } = true; public int BackchannelTimeoutSeconds { get; set; } = 30; public int TokenClockSkewSeconds { get; set; } = 60; public void Validate() { if (string.IsNullOrWhiteSpace(Authority)) { throw new InvalidOperationException("Policy Gateway resource server configuration requires an Authority URL."); } if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri)) { throw new InvalidOperationException("Policy Gateway resource server Authority URL must be absolute."); } if (RequireHttpsMetadata && !authorityUri.IsLoopback && !string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Policy Gateway resource server Authority URL must use HTTPS when metadata requires HTTPS."); } if (BackchannelTimeoutSeconds <= 0) { throw new InvalidOperationException("Policy Gateway resource server back-channel timeout must be greater than zero seconds."); } if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300) { throw new InvalidOperationException("Policy Gateway resource server token clock skew must be between 0 and 300 seconds."); } NormalizeList(Audiences, toLower: false); NormalizeList(RequiredScopes, toLower: true); NormalizeList(RequiredTenants, toLower: true); NormalizeList(BypassNetworks, toLower: false); } private static void NormalizeList(IList values, bool toLower) { if (values.Count == 0) { return; } var unique = new HashSet(StringComparer.OrdinalIgnoreCase); for (var index = values.Count - 1; index >= 0; index--) { var value = values[index]; if (string.IsNullOrWhiteSpace(value)) { values.RemoveAt(index); continue; } var normalized = value.Trim(); if (toLower) { normalized = normalized.ToLowerInvariant(); } if (!unique.Add(normalized)) { values.RemoveAt(index); continue; } values[index] = normalized; } } } /// /// Outbound Policy Engine configuration used by the gateway to forward requests. /// public sealed class PolicyGatewayPolicyEngineOptions { public string BaseAddress { get; set; } = "https://policy-engine.stella-ops.local"; public string Audience { get; set; } = "api://policy-engine"; public PolicyGatewayClientCredentialsOptions ClientCredentials { get; } = new(); public PolicyGatewayDpopOptions Dpop { get; } = new(); public void Validate() { if (string.IsNullOrWhiteSpace(BaseAddress)) { throw new InvalidOperationException("Policy Gateway requires a Policy Engine base address."); } if (!Uri.TryCreate(BaseAddress.Trim(), UriKind.Absolute, out var baseUri)) { throw new InvalidOperationException("Policy Gateway Policy Engine base address must be an absolute URI."); } if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && !baseUri.IsLoopback) { throw new InvalidOperationException("Policy Gateway Policy Engine base address must use HTTPS unless targeting loopback."); } if (string.IsNullOrWhiteSpace(Audience)) { throw new InvalidOperationException("Policy Gateway requires a Policy Engine audience value for client credential flows."); } ClientCredentials.Validate(); Dpop.Validate(); } public Uri BaseUri => new(BaseAddress, UriKind.Absolute); } /// /// Client credential configuration for the gateway when calling the Policy Engine. /// public sealed class PolicyGatewayClientCredentialsOptions { public bool Enabled { get; set; } = true; public string ClientId { get; set; } = "policy-gateway"; public string? ClientSecret { get; set; } = "change-me"; public IList Scopes { get; } = new List { StellaOpsScopes.PolicyRead, StellaOpsScopes.PolicyAuthor, StellaOpsScopes.PolicyReview, StellaOpsScopes.PolicyApprove, StellaOpsScopes.PolicyOperate, StellaOpsScopes.PolicySimulate, StellaOpsScopes.PolicyRun, StellaOpsScopes.PolicyActivate }; public int BackchannelTimeoutSeconds { get; set; } = 30; public void Validate() { if (!Enabled) { return; } if (string.IsNullOrWhiteSpace(ClientId)) { throw new InvalidOperationException("Policy Gateway client credential configuration requires a client identifier when enabled."); } if (Scopes.Count == 0) { throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one scope when enabled."); } var normalized = new HashSet(StringComparer.OrdinalIgnoreCase); for (var index = Scopes.Count - 1; index >= 0; index--) { var scope = Scopes[index]; if (string.IsNullOrWhiteSpace(scope)) { Scopes.RemoveAt(index); continue; } var trimmed = scope.Trim().ToLowerInvariant(); if (!normalized.Add(trimmed)) { Scopes.RemoveAt(index); continue; } Scopes[index] = trimmed; } if (Scopes.Count == 0) { throw new InvalidOperationException("Policy Gateway client credential configuration requires at least one non-empty scope when enabled."); } if (BackchannelTimeoutSeconds <= 0) { throw new InvalidOperationException("Policy Gateway client credential back-channel timeout must be greater than zero seconds."); } } public IReadOnlyList NormalizedScopes => new ReadOnlyCollection(Scopes); public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds); } /// /// DPoP sender-constrained credential configuration for outbound Policy Engine calls. /// public sealed class PolicyGatewayDpopOptions { public bool Enabled { get; set; } = false; public string KeyPath { get; set; } = string.Empty; public string? KeyPassphrase { get; set; } = null; public string Algorithm { get; set; } = "ES256"; public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2); public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30); public void Validate() { if (!Enabled) { return; } if (string.IsNullOrWhiteSpace(KeyPath)) { throw new InvalidOperationException("Policy Gateway DPoP configuration requires a key path when enabled."); } if (string.IsNullOrWhiteSpace(Algorithm)) { throw new InvalidOperationException("Policy Gateway DPoP configuration requires an algorithm when enabled."); } var normalizedAlgorithm = Algorithm.Trim().ToUpperInvariant(); if (normalizedAlgorithm is not ("ES256" or "ES384")) { throw new InvalidOperationException("Policy Gateway DPoP configuration supports only ES256 or ES384 algorithms."); } if (ProofLifetime <= TimeSpan.Zero) { throw new InvalidOperationException("Policy Gateway DPoP proof lifetime must be greater than zero."); } if (ClockSkew < TimeSpan.Zero || ClockSkew > TimeSpan.FromMinutes(5)) { throw new InvalidOperationException("Policy Gateway DPoP clock skew must be between 0 seconds and 5 minutes."); } Algorithm = normalizedAlgorithm; } }