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;
}
}