Restructure solution layout by module
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
406
src/Policy/StellaOps.Policy.Gateway/Program.cs
Normal file
406
src/Policy/StellaOps.Policy.Gateway/Program.cs
Normal 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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Policy.Gateway.Tests")]
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user