Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,15 @@
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
namespace StellaOps.Policy.Gateway.Clients;
internal interface IPolicyEngineClient
{
Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(GatewayForwardingContext? forwardingContext, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(GatewayForwardingContext? forwardingContext, CreatePolicyPackRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, CreatePolicyRevisionRequest request, CancellationToken cancellationToken);
Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(GatewayForwardingContext? forwardingContext, string packId, int version, ActivatePolicyRevisionRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineClient : IPolicyEngineClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient httpClient;
private readonly PolicyEngineTokenProvider tokenProvider;
private readonly ILogger<PolicyEngineClient> logger;
private readonly PolicyGatewayOptions options;
public PolicyEngineClient(
HttpClient httpClient,
IOptions<PolicyGatewayOptions> options,
PolicyEngineTokenProvider tokenProvider,
ILogger<PolicyEngineClient> logger)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
this.options = options.Value ?? throw new InvalidOperationException("Policy Gateway options must be configured.");
if (httpClient.BaseAddress is null)
{
httpClient.BaseAddress = this.options.PolicyEngine.BaseUri;
}
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
public Task<PolicyEngineResponse<IReadOnlyList<PolicyPackSummaryDto>>> ListPolicyPacksAsync(
GatewayForwardingContext? forwardingContext,
CancellationToken cancellationToken)
=> SendAsync<IReadOnlyList<PolicyPackSummaryDto>>(
HttpMethod.Get,
"api/policy/packs",
forwardingContext,
content: null,
cancellationToken);
public Task<PolicyEngineResponse<PolicyPackDto>> CreatePolicyPackAsync(
GatewayForwardingContext? forwardingContext,
CreatePolicyPackRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyPackDto>(
HttpMethod.Post,
"api/policy/packs",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionDto>> CreatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
CreatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions",
forwardingContext,
request,
cancellationToken);
public Task<PolicyEngineResponse<PolicyRevisionActivationDto>> ActivatePolicyRevisionAsync(
GatewayForwardingContext? forwardingContext,
string packId,
int version,
ActivatePolicyRevisionRequest request,
CancellationToken cancellationToken)
=> SendAsync<PolicyRevisionActivationDto>(
HttpMethod.Post,
$"api/policy/packs/{Uri.EscapeDataString(packId)}/revisions/{version}:activate",
forwardingContext,
request,
cancellationToken);
private async Task<PolicyEngineResponse<TSuccess>> SendAsync<TSuccess>(
HttpMethod method,
string relativeUri,
GatewayForwardingContext? forwardingContext,
object? content,
CancellationToken cancellationToken)
{
var absoluteUri = httpClient.BaseAddress is not null
? new Uri(httpClient.BaseAddress, relativeUri)
: new Uri(relativeUri, UriKind.Absolute);
using var request = new HttpRequestMessage(method, absoluteUri);
if (forwardingContext is not null)
{
forwardingContext.Apply(request);
}
else
{
var serviceAuthorization = await tokenProvider.GetAuthorizationAsync(method, absoluteUri, cancellationToken).ConfigureAwait(false);
if (serviceAuthorization is null)
{
logger.LogWarning(
"Policy Engine request {Method} {Uri} lacks caller credentials and client credentials flow is disabled.",
method,
absoluteUri);
var problem = new ProblemDetails
{
Title = "Upstream authorization missing",
Detail = "Caller did not present credentials and client credentials flow is disabled.",
Status = StatusCodes.Status401Unauthorized
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.Unauthorized, problem);
}
var authorization = serviceAuthorization.Value;
authorization.Apply(request);
}
if (content is not null)
{
request.Content = JsonContent.Create(content, options: SerializerOptions);
}
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var location = response.Headers.Location?.ToString();
if (response.IsSuccessStatusCode)
{
if (response.Content is null || response.Content.Headers.ContentLength == 0)
{
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, value: default, location);
}
try
{
var successValue = await response.Content.ReadFromJsonAsync<TSuccess>(SerializerOptions, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Success(response.StatusCode, successValue, location);
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to deserialize Policy Engine response for {Path}.", relativeUri);
var problem = new ProblemDetails
{
Title = "Invalid upstream response",
Detail = "Policy Engine returned an unexpected payload.",
Status = StatusCodes.Status502BadGateway
};
return PolicyEngineResponse<TSuccess>.Failure(HttpStatusCode.BadGateway, problem);
}
}
var problemDetails = await ReadProblemDetailsAsync(response, cancellationToken).ConfigureAwait(false);
return PolicyEngineResponse<TSuccess>.Failure(response.StatusCode, problemDetails);
}
private async Task<ProblemDetails?> ReadProblemDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content is null)
{
return null;
}
try
{
return await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
logger.LogDebug(ex, "Policy Engine returned non-ProblemDetails error response for {StatusCode}.", (int)response.StatusCode);
return new ProblemDetails
{
Title = "Upstream error",
Detail = $"Policy Engine responded with {(int)response.StatusCode} {response.ReasonPhrase}.",
Status = (int)response.StatusCode
};
}
}
}

View File

@@ -0,0 +1,31 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal sealed class PolicyEngineResponse<TSuccess>
{
private PolicyEngineResponse(HttpStatusCode statusCode, TSuccess? value, ProblemDetails? problem, string? location)
{
StatusCode = statusCode;
Value = value;
Problem = problem;
Location = location;
}
public HttpStatusCode StatusCode { get; }
public TSuccess? Value { get; }
public ProblemDetails? Problem { get; }
public string? Location { get; }
public bool IsSuccess => Problem is null && StatusCode is >= HttpStatusCode.OK and < HttpStatusCode.MultipleChoices;
public static PolicyEngineResponse<TSuccess> Success(HttpStatusCode statusCode, TSuccess? value, string? location)
=> new(statusCode, value, problem: null, location);
public static PolicyEngineResponse<TSuccess> Failure(HttpStatusCode statusCode, ProblemDetails? problem)
=> new(statusCode, value: default, problem, location: null);
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Policy.Gateway.Clients;
internal static class PolicyEngineResponseExtensions
{
public static IResult ToMinimalResult<T>(this PolicyEngineResponse<T> response)
{
if (response is null)
{
throw new ArgumentNullException(nameof(response));
}
if (response.IsSuccess)
{
return CreateSuccessResult(response);
}
return CreateErrorResult(response);
}
private static IResult CreateSuccessResult<T>(PolicyEngineResponse<T> response)
{
var value = response.Value;
switch (response.StatusCode)
{
case HttpStatusCode.Created:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Created(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status201Created);
case HttpStatusCode.Accepted:
if (!string.IsNullOrWhiteSpace(response.Location))
{
return Results.Accepted(response.Location, value);
}
return Results.Json(value, statusCode: StatusCodes.Status202Accepted);
case HttpStatusCode.NoContent:
return Results.NoContent();
default:
return Results.Json(value, statusCode: (int)response.StatusCode);
}
}
private static IResult CreateErrorResult<T>(PolicyEngineResponse<T> response)
{
var problem = response.Problem;
if (problem is null)
{
return Results.StatusCode((int)response.StatusCode);
}
var statusCode = problem.Status ?? (int)response.StatusCode;
return Results.Problem(
title: problem.Title,
detail: problem.Detail,
type: problem.Type,
instance: problem.Instance,
statusCode: statusCode,
extensions: problem.Extensions);
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Policy.Gateway.Contracts;
public sealed record PolicyPackSummaryDto(
string PackId,
string? DisplayName,
DateTimeOffset CreatedAt,
IReadOnlyList<int> Versions);
public sealed record PolicyPackDto(
string PackId,
string? DisplayName,
DateTimeOffset CreatedAt,
IReadOnlyList<PolicyRevisionDto> Revisions);
public sealed record PolicyRevisionDto(
int Version,
string Status,
bool RequiresTwoPersonApproval,
DateTimeOffset CreatedAt,
DateTimeOffset? ActivatedAt,
IReadOnlyList<PolicyActivationApprovalDto> Approvals);
public sealed record PolicyActivationApprovalDto(
string ActorId,
DateTimeOffset ApprovedAt,
string? Comment);
public sealed record PolicyRevisionActivationDto(
string Status,
PolicyRevisionDto Revision);
public sealed record CreatePolicyPackRequest(
[StringLength(200)] string? PackId,
[StringLength(200)] string? DisplayName);
public sealed record CreatePolicyRevisionRequest(
int? Version,
bool RequiresTwoPersonApproval,
string InitialStatus = "Approved");
public sealed record ActivatePolicyRevisionRequest(string? Comment);

View File

@@ -0,0 +1,59 @@
using System;
using System.Net.Http;
using Microsoft.AspNetCore.Http;
namespace StellaOps.Policy.Gateway.Infrastructure;
internal sealed record GatewayForwardingContext(string Authorization, string? Dpop, string? Tenant)
{
private static readonly string[] ForwardedHeaders =
{
"Authorization",
"DPoP",
"X-Stella-Tenant"
};
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
request.Headers.TryAddWithoutValidation(ForwardedHeaders[0], Authorization);
if (!string.IsNullOrWhiteSpace(Dpop))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[1], Dpop);
}
if (!string.IsNullOrWhiteSpace(Tenant))
{
request.Headers.TryAddWithoutValidation(ForwardedHeaders[2], Tenant);
}
}
public static bool TryCreate(HttpContext context, out GatewayForwardingContext forwardingContext)
{
ArgumentNullException.ThrowIfNull(context);
var authorization = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
forwardingContext = null!;
return false;
}
var dpop = context.Request.Headers["DPoP"].ToString();
if (string.IsNullOrWhiteSpace(dpop))
{
dpop = null;
}
var tenant = context.Request.Headers["X-Stella-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenant))
{
tenant = null;
}
forwardingContext = new GatewayForwardingContext(authorization.Trim(), dpop, tenant);
return true;
}
}

View File

@@ -0,0 +1,323 @@
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;
}
}

View File

@@ -0,0 +1,406 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Polly;
using Polly.Extensions.Http;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(contentRoot, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
});
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyGatewayOptions>(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_GATEWAY_";
options.BindingSection = PolicyGatewayOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
foreach (var relative in new[]
{
"../etc/policy-gateway.yaml",
"../etc/policy-gateway.local.yaml",
"policy-gateway.yaml",
"policy-gateway.local.yaml"
})
{
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
options.PostBind = static (value, _) => value.Validate();
});
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.Logging.SetMinimumLevel(bootstrap.Options.Telemetry.MinimumLogLevel);
builder.Services.AddOptions<PolicyGatewayOptions>()
.Bind(builder.Configuration.GetSection(PolicyGatewayOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyGatewayOptions.SectionName,
typeof(PolicyGatewayOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
builder.Services.AddSingleton<PolicyGatewayMetrics>();
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
builder.Services.AddSingleton<PolicyEngineTokenProvider>();
builder.Services.AddTransient<PolicyGatewayDpopHandler>();
if (bootstrap.Options.PolicyEngine.ClientCredentials.Enabled)
{
builder.Services.AddOptions<StellaOpsAuthClientOptions>()
.Configure(options =>
{
options.Authority = bootstrap.Options.ResourceServer.Authority;
options.ClientId = bootstrap.Options.PolicyEngine.ClientCredentials.ClientId;
options.ClientSecret = bootstrap.Options.PolicyEngine.ClientCredentials.ClientSecret;
options.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
foreach (var scope in bootstrap.Options.PolicyEngine.ClientCredentials.Scopes)
{
options.DefaultScopes.Add(scope);
}
})
.PostConfigure(static opt => opt.Validate());
builder.Services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
builder.Services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider));
builder.Services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = authOptions.HttpTimeout;
})
.AddPolicyHandler(static (provider, _) => CreateAuthorityRetryPolicy(provider))
.AddHttpMessageHandler<PolicyGatewayDpopHandler>();
}
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
{
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
})
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
var app = builder.Build();
app.UseExceptionHandler(static appBuilder => appBuilder.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Unexpected gateway error." });
}));
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
var policyPacks = app.MapGroup("/api/policy/packs")
.WithTags("Policy Packs");
policyPacks.MapGet(string.Empty, async Task<IResult> (
HttpContext context,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.ListPolicyPacksAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
policyPacks.MapPost(string.Empty, async Task<IResult> (
HttpContext context,
CreatePolicyPackRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyPackAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions", async Task<IResult> (
HttpContext context,
string packId,
CreatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var response = await client.CreatePolicyRevisionAsync(forwardingContext, packId, request, cancellationToken).ConfigureAwait(false);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
policyPacks.MapPost("/{packId}/revisions/{version:int}:activate", async Task<IResult> (
HttpContext context,
string packId,
int version,
ActivatePolicyRevisionRequest request,
IPolicyEngineClient client,
PolicyEngineTokenProvider tokenProvider,
PolicyGatewayMetrics metrics,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(packId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "packId is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required.",
Status = StatusCodes.Status400BadRequest
});
}
GatewayForwardingContext? forwardingContext = null;
var source = "service";
if (GatewayForwardingContext.TryCreate(context, out var callerContext))
{
forwardingContext = callerContext;
source = "caller";
}
else if (!tokenProvider.IsEnabled)
{
return Results.Unauthorized();
}
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.ActivatePolicyRevisionAsync(forwardingContext, packId, version, request, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
var outcome = DetermineActivationOutcome(response);
metrics.RecordActivation(outcome, source, stopwatch.Elapsed.TotalMilliseconds);
var logger = loggerFactory.CreateLogger("StellaOps.Policy.Gateway.Activation");
LogActivation(logger, packId, version, outcome, source, response.StatusCode);
return response.ToMinimalResult();
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.PolicyOperate,
StellaOpsScopes.PolicyActivate));
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
{
var authOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = authOptions.NormalizedRetryDelays;
if (delays.Count == 0)
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var loggerFactory = provider.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("PolicyGateway.AuthorityHttp");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static message => message.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
logger?.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{Total}) after {Reason}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString(),
delay);
});
}
static IAsyncPolicy<HttpResponseMessage> CreatePolicyEngineRetryPolicy(IServiceProvider provider)
=> HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout)
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
static string DetermineActivationOutcome(PolicyEngineResponse<PolicyRevisionActivationDto> response)
{
if (response.IsSuccess)
{
return response.Value?.Status switch
{
"activated" => "activated",
"already_active" => "already_active",
"pending_second_approval" => "pending_second_approval",
_ => "success"
};
}
return response.StatusCode switch
{
HttpStatusCode.BadRequest => "bad_request",
HttpStatusCode.NotFound => "not_found",
HttpStatusCode.Unauthorized => "unauthorized",
HttpStatusCode.Forbidden => "forbidden",
_ => "error"
};
}
static void LogActivation(ILogger logger, string packId, int version, string outcome, string source, HttpStatusCode statusCode)
{
if (logger is null)
{
return;
}
var message = "Policy activation forwarded.";
var logLevel = outcome is "activated" or "already_active" or "pending_second_approval" ? LogLevel.Information : LogLevel.Warning;
logger.Log(logLevel, message + " Outcome={Outcome}; Source={Source}; PackId={PackId}; Version={Version}; StatusCode={StatusCode}.", outcome, source, packId, version, (int)statusCode);
}
public partial class Program
{
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Policy.Gateway.Tests")]

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyEngineTokenProvider
{
private readonly IStellaOpsTokenClient tokenClient;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator dpopGenerator;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyEngineTokenProvider> logger;
private readonly SemaphoreSlim mutex = new(1, 1);
private CachedToken? cachedToken;
public PolicyEngineTokenProvider(
IStellaOpsTokenClient tokenClient,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator dpopGenerator,
TimeProvider timeProvider,
ILogger<PolicyEngineTokenProvider> logger)
{
this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.dpopGenerator = dpopGenerator ?? throw new ArgumentNullException(nameof(dpopGenerator));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsEnabled => optionsMonitor.CurrentValue.PolicyEngine.ClientCredentials.Enabled;
public async ValueTask<PolicyGatewayAuthorization?> GetAuthorizationAsync(HttpMethod method, Uri targetUri, CancellationToken cancellationToken)
{
if (!IsEnabled)
{
return null;
}
var tokenResult = await GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (tokenResult is null)
{
return null;
}
var token = tokenResult.Value;
string? proof = null;
if (dpopGenerator.Enabled)
{
proof = dpopGenerator.CreateProof(method, targetUri, token.AccessToken);
}
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase)
? "DPoP"
: token.TokenType;
var authorization = $"{scheme} {token.AccessToken}";
return new PolicyGatewayAuthorization(authorization, proof, "service");
}
private async ValueTask<CachedToken?> GetTokenAsync(CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue.PolicyEngine;
if (!options.ClientCredentials.Enabled)
{
return null;
}
var now = timeProvider.GetUtcNow();
if (cachedToken is { } existing && existing.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return existing;
}
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedToken is { } cached && cached.ExpiresAt > now + TimeSpan.FromSeconds(30))
{
return cached;
}
var scopeString = BuildScopeClaim(options);
var result = await tokenClient.RequestClientCredentialsTokenAsync(scopeString, null, cancellationToken).ConfigureAwait(false);
var expiresAt = result.ExpiresAtUtc;
cachedToken = new CachedToken(result.AccessToken, string.IsNullOrWhiteSpace(result.TokenType) ? "Bearer" : result.TokenType, expiresAt);
logger.LogInformation("Issued Policy Engine client credentials token; expires at {ExpiresAt:o}.", expiresAt);
return cachedToken;
}
finally
{
mutex.Release();
}
}
private string BuildScopeClaim(PolicyGatewayPolicyEngineOptions options)
{
var scopeSet = new SortedSet<string>(StringComparer.Ordinal)
{
$"aud:{options.Audience.Trim().ToLowerInvariant()}"
};
foreach (var scope in options.ClientCredentials.Scopes)
{
if (string.IsNullOrWhiteSpace(scope))
{
continue;
}
scopeSet.Add(scope.Trim());
}
return string.Join(' ', scopeSet);
}
private readonly record struct CachedToken(string AccessToken, string TokenType, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Net.Http;
namespace StellaOps.Policy.Gateway.Services;
internal readonly record struct PolicyGatewayAuthorization(string AuthorizationHeader, string? DpopProof, string Source)
{
public void Apply(HttpRequestMessage request)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.IsNullOrWhiteSpace(AuthorizationHeader))
{
request.Headers.Remove("Authorization");
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationHeader);
}
if (!string.IsNullOrWhiteSpace(DpopProof))
{
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", DpopProof);
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopHandler : DelegatingHandler
{
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly PolicyGatewayDpopProofGenerator proofGenerator;
public PolicyGatewayDpopHandler(
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
PolicyGatewayDpopProofGenerator proofGenerator)
{
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (options.Enabled &&
proofGenerator.Enabled &&
request.Method == HttpMethod.Post &&
request.RequestUri is { } uri &&
uri.AbsolutePath.Contains("/token", StringComparison.OrdinalIgnoreCase))
{
var proof = proofGenerator.CreateProof(request.Method, uri, accessToken: null);
request.Headers.Remove("DPoP");
request.Headers.TryAddWithoutValidation("DPoP", proof);
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
{
private readonly IHostEnvironment hostEnvironment;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
private DpopKeyMaterial? keyMaterial;
private readonly object sync = new();
public PolicyGatewayDpopProofGenerator(
IHostEnvironment hostEnvironment,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<PolicyGatewayDpopProofGenerator> logger)
{
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool Enabled
{
get
{
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
return options.Enabled;
}
}
public string CreateProof(HttpMethod method, Uri targetUri, string? accessToken)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(targetUri);
if (!Enabled)
{
throw new InvalidOperationException("DPoP proof requested while DPoP is disabled.");
}
var material = GetOrLoadKeyMaterial();
var header = CreateHeader(material);
var payload = CreatePayload(method, targetUri, accessToken);
var jwt = new JwtSecurityToken(header, payload);
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(jwt);
}
private JwtHeader CreateHeader(DpopKeyMaterial material)
{
var header = new JwtHeader(new SigningCredentials(material.SecurityKey, material.SigningAlgorithm));
header["typ"] = "dpop+jwt";
header["jwk"] = new Dictionary<string, object>
{
["kty"] = material.Jwk.Kty,
["crv"] = material.Jwk.Crv,
["x"] = material.Jwk.X,
["y"] = material.Jwk.Y,
["kid"] = material.Jwk.Kid
};
return header;
}
private JwtPayload CreatePayload(HttpMethod method, Uri targetUri, string? accessToken)
{
var now = timeProvider.GetUtcNow();
var epochSeconds = (long)Math.Floor((now - DateTimeOffset.UnixEpoch).TotalSeconds);
var payload = new JwtPayload
{
["htm"] = method.Method.ToUpperInvariant(),
["htu"] = NormalizeTarget(targetUri),
["iat"] = epochSeconds,
["jti"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(accessToken))
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(accessToken));
payload["ath"] = Base64UrlEncoder.Encode(hash);
}
return payload;
}
private static string NormalizeTarget(Uri uri)
{
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException("DPoP proofs require absolute target URIs.");
}
return uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped);
}
private DpopKeyMaterial GetOrLoadKeyMaterial()
{
if (keyMaterial is not null)
{
return keyMaterial;
}
lock (sync)
{
if (keyMaterial is not null)
{
return keyMaterial;
}
var options = optionsMonitor.CurrentValue.PolicyEngine.Dpop;
if (!options.Enabled)
{
throw new InvalidOperationException("DPoP is not enabled in the current configuration.");
}
var resolvedPath = ResolveKeyPath(options.KeyPath);
if (!File.Exists(resolvedPath))
{
throw new FileNotFoundException($"DPoP key file not found at '{resolvedPath}'.", resolvedPath);
}
var pem = File.ReadAllText(resolvedPath);
ECDsa ecdsa;
try
{
ecdsa = ECDsa.Create();
if (!string.IsNullOrWhiteSpace(options.KeyPassphrase))
{
ecdsa.ImportFromEncryptedPem(pem, options.KeyPassphrase);
}
else
{
ecdsa.ImportFromPem(pem);
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to load DPoP private key.", ex);
}
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = ComputeKeyId(ecdsa)
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
jwk.Kid ??= securityKey.KeyId;
keyMaterial = new DpopKeyMaterial(ecdsa, securityKey, jwk, MapAlgorithm(options.Algorithm));
logger.LogInformation("Loaded DPoP key from {Path} (alg: {Algorithm}).", resolvedPath, options.Algorithm);
return keyMaterial;
}
}
private string ResolveKeyPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
return Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, path));
}
private static string ComputeKeyId(ECDsa ecdsa)
{
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
var buffer = new byte[(parameters.Q.X?.Length ?? 0) + (parameters.Q.Y?.Length ?? 0)];
var offset = 0;
if (parameters.Q.X is not null)
{
Buffer.BlockCopy(parameters.Q.X, 0, buffer, offset, parameters.Q.X.Length);
offset += parameters.Q.X.Length;
}
if (parameters.Q.Y is not null)
{
Buffer.BlockCopy(parameters.Q.Y, 0, buffer, offset, parameters.Q.Y.Length);
}
var hash = SHA256.HashData(buffer);
return Base64UrlEncoder.Encode(hash);
}
private static string MapAlgorithm(string algorithm)
=> algorithm switch
{
"ES256" => SecurityAlgorithms.EcdsaSha256,
"ES384" => SecurityAlgorithms.EcdsaSha384,
_ => throw new InvalidOperationException($"Unsupported DPoP signing algorithm '{algorithm}'.")
};
public void Dispose()
{
if (keyMaterial is { } material)
{
material.Dispose();
}
}
private sealed class DpopKeyMaterial : IDisposable
{
public DpopKeyMaterial(ECDsa ecdsa, ECDsaSecurityKey securityKey, JsonWebKey jwk, string signingAlgorithm)
{
Ecdsa = ecdsa;
SecurityKey = securityKey;
Jwk = jwk;
SigningAlgorithm = signingAlgorithm;
}
public ECDsa Ecdsa { get; }
public ECDsaSecurityKey SecurityKey { get; }
public JsonWebKey Jwk { get; }
public string SigningAlgorithm { get; }
public void Dispose()
{
Ecdsa.Dispose();
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Gateway.Services;
internal sealed class PolicyGatewayMetrics : IDisposable
{
private static readonly KeyValuePair<string, object?>[] EmptyTags = Array.Empty<KeyValuePair<string, object?>>();
private readonly Meter meter;
public PolicyGatewayMetrics()
{
meter = new Meter("StellaOps.Policy.Gateway", "1.0.0");
ActivationRequests = meter.CreateCounter<long>(
"policy_gateway_activation_requests_total",
unit: "count",
description: "Total policy activation proxy requests processed by the gateway.");
ActivationLatencyMs = meter.CreateHistogram<double>(
"policy_gateway_activation_latency_ms",
unit: "ms",
description: "Latency distribution for policy activation proxy calls.");
}
public Counter<long> ActivationRequests { get; }
public Histogram<double> ActivationLatencyMs { get; }
public void RecordActivation(string outcome, string source, double elapsedMilliseconds)
{
var tags = BuildTags(outcome, source);
ActivationRequests.Add(1, tags);
ActivationLatencyMs.Record(elapsedMilliseconds, tags);
}
private static KeyValuePair<string, object?>[] BuildTags(string outcome, string source)
{
outcome = string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome;
source = string.IsNullOrWhiteSpace(source) ? "unspecified" : source;
return new[]
{
new KeyValuePair<string, object?>("outcome", outcome),
new KeyValuePair<string, object?>("source", source)
};
}
public void Dispose()
{
meter.Dispose();
}
}

View File

@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
</Project>