736 lines
28 KiB
C#
736 lines
28 KiB
C#
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using NetEscapades.Configuration.Yaml;
|
|
using Polly;
|
|
using Polly.Extensions.Http;
|
|
using StellaOps.AirGap.Policy;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.Client;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Configuration;
|
|
using StellaOps.Determinism;
|
|
using StellaOps.Policy.Deltas;
|
|
using StellaOps.Policy.Engine.Gates;
|
|
using StellaOps.Policy.Gateway.Clients;
|
|
using StellaOps.Policy.Gateway.Contracts;
|
|
using StellaOps.Policy.Gateway.Endpoints;
|
|
using StellaOps.Policy.Gateway.Infrastructure;
|
|
using StellaOps.Policy.Gateway.Options;
|
|
using StellaOps.Policy.Gateway.Services;
|
|
using StellaOps.Policy.Persistence.Postgres;
|
|
using StellaOps.Policy.Snapshots;
|
|
using StellaOps.Policy.ToolLattice;
|
|
using System;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Net.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.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
|
|
|
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.AddOptions<ToolLatticeOptions>()
|
|
.Bind(builder.Configuration.GetSection($"{PolicyGatewayOptions.SectionName}:{ToolLatticeOptions.SectionName}"))
|
|
.Validate(options =>
|
|
{
|
|
try
|
|
{
|
|
options.Validate();
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new OptionsValidationException(
|
|
ToolLatticeOptions.SectionName,
|
|
typeof(ToolLatticeOptions),
|
|
new[] { ex.Message });
|
|
}
|
|
})
|
|
.ValidateOnStart();
|
|
|
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddSystemGuidProvider();
|
|
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
|
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddHealthChecks();
|
|
builder.Services.AddAuthentication();
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddStellaOpsScopeHandler();
|
|
builder.Services.AddPolicyPostgresStorage(builder.Configuration);
|
|
// Also configure unnamed PostgresOptions so PolicyDataSource (IOptions<PostgresOptions>) resolves the connection string.
|
|
builder.Services.Configure<StellaOps.Infrastructure.Postgres.Options.PostgresOptions>(
|
|
builder.Configuration.GetSection("Postgres:Policy"));
|
|
builder.Services.AddMemoryCache();
|
|
|
|
// Exception services
|
|
builder.Services.Configure<ApprovalWorkflowOptions>(
|
|
builder.Configuration.GetSection(ApprovalWorkflowOptions.SectionName));
|
|
builder.Services.Configure<ExceptionExpiryOptions>(
|
|
builder.Configuration.GetSection(ExceptionExpiryOptions.SectionName));
|
|
builder.Services.AddScoped<IExceptionService, ExceptionService>();
|
|
builder.Services.AddScoped<IExceptionQueryService, ExceptionQueryService>();
|
|
builder.Services.AddScoped<IApprovalWorkflowService, ApprovalWorkflowService>();
|
|
builder.Services.AddSingleton<IExceptionNotificationService, NoOpExceptionNotificationService>();
|
|
builder.Services.AddHostedService<ExceptionExpiryWorker>();
|
|
|
|
// Delta services
|
|
builder.Services.AddScoped<IDeltaComputer, DeltaComputer>();
|
|
builder.Services.AddScoped<IBaselineSelector, BaselineSelector>();
|
|
builder.Services.AddScoped<ISnapshotStore, InMemorySnapshotStore>();
|
|
builder.Services.AddScoped<StellaOps.Policy.Deltas.ISnapshotService, DeltaSnapshotServiceAdapter>();
|
|
|
|
// Gate services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
|
builder.Services.Configure<DriftGateOptions>(
|
|
builder.Configuration.GetSection(DriftGateOptions.SectionName));
|
|
builder.Services.AddScoped<IDriftGateEvaluator, DriftGateEvaluator>();
|
|
builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
|
|
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
|
|
builder.Services.AddHostedService<GateEvaluationWorker>();
|
|
|
|
// Unknowns gate services (Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement)
|
|
builder.Services.Configure<StellaOps.Policy.Gates.UnknownsGateOptions>(_ => { });
|
|
builder.Services.AddHttpClient<StellaOps.Policy.Gates.IUnknownsGateChecker, StellaOps.Policy.Gates.UnknownsGateChecker>();
|
|
|
|
// Gate bypass audit services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration, Task: CICD-GATE-06)
|
|
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
|
|
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOptions>();
|
|
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
|
|
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
|
|
|
|
// Score-based gate services (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
|
builder.Services.AddSingleton<StellaOps.Signals.EvidenceWeightedScore.IEvidenceWeightedScoreCalculator,
|
|
StellaOps.Signals.EvidenceWeightedScore.EvidenceWeightedScoreCalculator>();
|
|
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IGateEvaluator,
|
|
StellaOps.DeltaVerdict.Bundles.GateEvaluator>();
|
|
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictBundleBuilder,
|
|
StellaOps.DeltaVerdict.Bundles.VerdictBundleBuilder>();
|
|
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictSigningService,
|
|
StellaOps.DeltaVerdict.Bundles.VerdictSigningService>();
|
|
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Signing.IRekorSubmissionClient,
|
|
StellaOps.DeltaVerdict.Bundles.StubVerdictRekorClient>();
|
|
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictRekorAnchorService,
|
|
StellaOps.DeltaVerdict.Bundles.VerdictRekorAnchorService>();
|
|
|
|
// Exception approval services (Sprint: SPRINT_20251226_003_BE_exception_approval)
|
|
builder.Services.Configure<StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions>(
|
|
builder.Configuration.GetSection(StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions.SectionName));
|
|
builder.Services.AddScoped<StellaOps.Policy.Persistence.Postgres.Repositories.IExceptionApprovalRepository,
|
|
StellaOps.Policy.Persistence.Postgres.Repositories.ExceptionApprovalRepository>();
|
|
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IExceptionApprovalRulesService,
|
|
StellaOps.Policy.Engine.Services.ExceptionApprovalRulesService>();
|
|
|
|
builder.Services.AddSingleton<IToolAccessEvaluator, ToolAccessEvaluator>();
|
|
|
|
builder.Services.AddStellaOpsResourceServerAuthentication(
|
|
builder.Configuration,
|
|
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer",
|
|
configure: resourceOptions =>
|
|
{
|
|
// IConfiguration binder does not always clear default list values.
|
|
// When local compose sets Audiences to an empty value, explicitly clear
|
|
// the audience list so no-aud local tokens can be validated.
|
|
var audiences = builder.Configuration
|
|
.GetSection($"{PolicyGatewayOptions.SectionName}:ResourceServer:Audiences")
|
|
.Get<string[]>();
|
|
if (audiences is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
resourceOptions.Audiences.Clear();
|
|
foreach (var audience in audiences)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(audience))
|
|
{
|
|
resourceOptions.Audiences.Add(audience.Trim());
|
|
}
|
|
}
|
|
});
|
|
|
|
// Accept self-signed certificates when HTTPS metadata validation is disabled (dev/Docker)
|
|
if (!bootstrap.Options.ResourceServer.RequireHttpsMetadata)
|
|
{
|
|
builder.Services.ConfigureHttpClientDefaults(clientBuilder =>
|
|
{
|
|
clientBuilder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
|
{
|
|
ServerCertificateCustomValidationCallback =
|
|
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
|
});
|
|
});
|
|
}
|
|
|
|
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>();
|
|
}
|
|
else
|
|
{
|
|
// Keep DI graph valid when client credentials are disabled.
|
|
builder.Services.AddSingleton<IStellaOpsTokenClient, DisabledStellaOpsTokenClient>();
|
|
}
|
|
|
|
builder.Services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>((serviceProvider, client) =>
|
|
{
|
|
var gatewayOptions = serviceProvider.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value;
|
|
var egressPolicy = serviceProvider.GetService<IEgressPolicy>();
|
|
if (egressPolicy is not null)
|
|
{
|
|
egressPolicy.EnsureAllowed(new EgressRequest("PolicyGateway", gatewayOptions.PolicyEngine.BaseUri, "policy-engine-client"));
|
|
}
|
|
client.BaseAddress = gatewayOptions.PolicyEngine.BaseUri;
|
|
client.Timeout = TimeSpan.FromSeconds(gatewayOptions.PolicyEngine.ClientCredentials.BackchannelTimeoutSeconds);
|
|
})
|
|
.AddPolicyHandler(static (provider, _) => CreatePolicyEngineRetryPolicy(provider));
|
|
|
|
builder.TryAddStellaOpsLocalBinding("policy-gateway");
|
|
var app = builder.Build();
|
|
app.LogStellaOpsLocalHostname("policy-gateway");
|
|
|
|
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.UseStellaOpsCors();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapHealthChecks("/healthz");
|
|
|
|
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
|
|
.WithName("Readiness");
|
|
|
|
app.MapGet("/", () => Results.Redirect("/healthz"));
|
|
|
|
app.MapGet("/api/policy/quota", ([FromServices] TimeProvider timeProvider) =>
|
|
{
|
|
var now = timeProvider.GetUtcNow();
|
|
var resetAt = now.Date.AddDays(1).ToString("O", CultureInfo.InvariantCulture);
|
|
return Results.Ok(new
|
|
{
|
|
simulationsPerDay = 1000,
|
|
simulationsUsed = 0,
|
|
evaluationsPerDay = 5000,
|
|
evaluationsUsed = 0,
|
|
resetAt
|
|
});
|
|
})
|
|
.WithTags("Policy Quota")
|
|
.WithName("PolicyQuota.Get")
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
|
|
|
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));
|
|
|
|
var cvss = app.MapGroup("/api/cvss")
|
|
.WithTags("CVSS Receipts");
|
|
|
|
cvss.MapPost("/receipts", async Task<IResult>(
|
|
HttpContext context,
|
|
CreateCvssReceiptRequest 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.CreateCvssReceiptAsync(forwardingContext, request, cancellationToken).ConfigureAwait(false);
|
|
return response.ToMinimalResult();
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
|
|
|
|
cvss.MapGet("/receipts/{receiptId}", async Task<IResult>(
|
|
HttpContext context,
|
|
string receiptId,
|
|
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.GetCvssReceiptAsync(forwardingContext, receiptId, cancellationToken).ConfigureAwait(false);
|
|
return response.ToMinimalResult();
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
|
|
|
cvss.MapPut("/receipts/{receiptId}/amend", async Task<IResult>(
|
|
HttpContext context,
|
|
string receiptId,
|
|
AmendCvssReceiptRequest 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.AmendCvssReceiptAsync(forwardingContext, receiptId, request, cancellationToken).ConfigureAwait(false);
|
|
return response.ToMinimalResult();
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
|
|
|
|
cvss.MapGet("/receipts/{receiptId}/history", async Task<IResult>(
|
|
HttpContext context,
|
|
string receiptId,
|
|
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.GetCvssReceiptHistoryAsync(forwardingContext, receiptId, cancellationToken).ConfigureAwait(false);
|
|
return response.ToMinimalResult();
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
|
|
|
cvss.MapGet("/policies", 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.ListCvssPoliciesAsync(forwardingContext, cancellationToken).ConfigureAwait(false);
|
|
return response.ToMinimalResult();
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
|
|
|
|
// Exception management endpoints
|
|
app.MapExceptionEndpoints();
|
|
|
|
// Delta management endpoints
|
|
app.MapDeltasEndpoints();
|
|
|
|
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
|
app.MapGateEndpoints();
|
|
|
|
// Unknowns gate endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
|
app.MapGatesEndpoints();
|
|
|
|
// Score-based gate evaluation endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
|
app.MapScoreGateEndpoints();
|
|
|
|
// Registry webhook endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
|
app.MapRegistryWebhooks();
|
|
|
|
// Exception approval endpoints (Sprint: SPRINT_20251226_003_BE_exception_approval)
|
|
app.MapExceptionApprovalEndpoints();
|
|
|
|
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
|
|
app.MapGovernanceEndpoints();
|
|
|
|
// Advisory source impact/conflict endpoints (Sprint: SPRINT_20260219_008, Task: BE8-05)
|
|
app.MapAdvisorySourcePolicyEndpoints();
|
|
|
|
// Assistant tool lattice endpoints (Sprint: SPRINT_20260113_005_POLICY_assistant_tool_lattice)
|
|
app.MapToolLatticeEndpoints();
|
|
|
|
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);
|
|
}
|
|
|
|
// Make Program class public for WebApplicationFactory test support
|
|
namespace StellaOps.Policy.Gateway
|
|
{
|
|
public partial class Program { }
|
|
}
|