Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Gateway/Options/PolicyGatewayOptions.cs
StellaOps Bot 564df71bfb
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
up
2025-12-13 00:20:26 +02:00

324 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Policy.Gateway.Options;
/// <summary>
/// Root configuration for the Policy Gateway host.
/// </summary>
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();
}
}
/// <summary>
/// Logging and telemetry configuration for the gateway.
/// </summary>
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.");
}
}
}
/// <summary>
/// JWT resource server configuration for incoming requests handled by the gateway.
/// </summary>
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<string> Audiences { get; } = new List<string> { "api://policy-gateway" };
public IList<string> RequiredScopes { get; } = new List<string>
{
StellaOpsScopes.PolicyRead,
StellaOpsScopes.PolicyAuthor,
StellaOpsScopes.PolicyReview,
StellaOpsScopes.PolicyApprove,
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicySimulate,
StellaOpsScopes.PolicyRun,
StellaOpsScopes.PolicyActivate
};
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string> { "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<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var unique = new HashSet<string>(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;
}
}
}
/// <summary>
/// Outbound Policy Engine configuration used by the gateway to forward requests.
/// </summary>
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);
}
/// <summary>
/// Client credential configuration for the gateway when calling the Policy Engine.
/// </summary>
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<string> Scopes { get; } = new List<string>
{
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<string>(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<string> NormalizedScopes => new ReadOnlyCollection<string>(Scopes);
public TimeSpan BackchannelTimeout => TimeSpan.FromSeconds(BackchannelTimeoutSeconds);
}
/// <summary>
/// DPoP sender-constrained credential configuration for outbound Policy Engine calls.
/// </summary>
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;
}
}