sprints work
This commit is contained in:
481
src/Replay/StellaOps.Replay.WebService/Program.cs
Normal file
481
src/Replay/StellaOps.Replay.WebService/Program.cs
Normal file
@@ -0,0 +1,481 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Program.cs
|
||||
// StellaOps Replay Token WebService
|
||||
// Sprint: SPRINT_5100_0010_0001 - EvidenceLocker + Findings Ledger + Replay Test Implementation
|
||||
// Task: REPLAY-5100-004 - Replay.WebService for token issuance and verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Audit.ReplayToken;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
const string ReplayReadPolicy = "replay.token.read";
|
||||
const string ReplayWritePolicy = "replay.token.write";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "REPLAY_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
configurationBuilder.AddYamlFile("../etc/replay.yaml", optional: true, reloadOnChange: true);
|
||||
};
|
||||
});
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<ReplayServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ReplayServiceOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ICryptoHash, DefaultCryptoHash>();
|
||||
builder.Services.AddSingleton<IReplayTokenGenerator, Sha256ReplayTokenGenerator>();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
builder.Services.AddStellaOpsTelemetry(builder.Configuration, "replay-webservice");
|
||||
|
||||
var authConfig = builder.Configuration.GetSection("Replay:Authority").Get<AuthorityConfig>() ?? new AuthorityConfig();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authConfig.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = authConfig.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = authConfig.MetadataAddress;
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in authConfig.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in authConfig.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(ReplayReadPolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.VulnOperate }));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
|
||||
options.AddPolicy(ReplayWritePolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.VulnOperate }));
|
||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseExceptionHandler(exceptionApp =>
|
||||
{
|
||||
exceptionApp.Run(async context =>
|
||||
{
|
||||
var feature = context.Features.Get<IExceptionHandlerFeature>();
|
||||
if (feature?.Error is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var problem = Results.Problem(
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "replay_internal_error",
|
||||
detail: feature.Error.Message);
|
||||
await problem.ExecuteAsync(context);
|
||||
});
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
// POST /v1/replay/tokens - Generate a new replay token
|
||||
app.MapPost("/v1/replay/tokens", Task<Results<Created<GenerateTokenResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
GenerateTokenRequest request,
|
||||
IReplayTokenGenerator tokenGenerator,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return Task.FromResult<Results<Created<GenerateTokenResponse>, ProblemHttpResult>>(tenantProblem!);
|
||||
}
|
||||
|
||||
var tokenRequest = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = request.FeedManifests ?? Array.Empty<string>(),
|
||||
RulesVersion = request.RulesVersion,
|
||||
RulesHash = request.RulesHash,
|
||||
LatticePolicyVersion = request.LatticePolicyVersion,
|
||||
LatticePolicyHash = request.LatticePolicyHash,
|
||||
InputHashes = request.InputHashes ?? Array.Empty<string>(),
|
||||
ScoringConfigVersion = request.ScoringConfigVersion,
|
||||
EvidenceHashes = request.EvidenceHashes ?? Array.Empty<string>(),
|
||||
AdditionalContext = request.AdditionalContext ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var expiration = request.ExpirationMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.ExpirationMinutes.Value)
|
||||
: ReplayToken.DefaultExpiration;
|
||||
|
||||
var token = request.WithExpiration
|
||||
? tokenGenerator.GenerateWithExpiration(tokenRequest, expiration)
|
||||
: tokenGenerator.Generate(tokenRequest);
|
||||
|
||||
var response = new GenerateTokenResponse(
|
||||
token.Canonical,
|
||||
token.Value,
|
||||
token.GeneratedAt,
|
||||
token.ExpiresAt,
|
||||
token.Algorithm,
|
||||
token.Version);
|
||||
|
||||
return Task.FromResult<Results<Created<GenerateTokenResponse>, ProblemHttpResult>>(
|
||||
TypedResults.Created($"/v1/replay/tokens/{token.Value}", response));
|
||||
})
|
||||
.WithName("GenerateReplayToken")
|
||||
.RequireAuthorization(ReplayWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// POST /v1/replay/tokens/verify - Verify a replay token
|
||||
app.MapPost("/v1/replay/tokens/verify", Task<Results<Ok<VerifyTokenResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
VerifyTokenRequest request,
|
||||
IReplayTokenGenerator tokenGenerator,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return Task.FromResult<Results<Ok<VerifyTokenResponse>, ProblemHttpResult>>(tenantProblem!);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
return Task.FromResult<Results<Ok<VerifyTokenResponse>, ProblemHttpResult>>(
|
||||
TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_token", detail: "Token is required."));
|
||||
}
|
||||
|
||||
ReplayToken parsedToken;
|
||||
try
|
||||
{
|
||||
parsedToken = ReplayToken.Parse(request.Token);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Task.FromResult<Results<Ok<VerifyTokenResponse>, ProblemHttpResult>>(
|
||||
TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_token_format", detail: ex.Message));
|
||||
}
|
||||
|
||||
var tokenRequest = new ReplayTokenRequest
|
||||
{
|
||||
FeedManifests = request.FeedManifests ?? Array.Empty<string>(),
|
||||
RulesVersion = request.RulesVersion,
|
||||
RulesHash = request.RulesHash,
|
||||
LatticePolicyVersion = request.LatticePolicyVersion,
|
||||
LatticePolicyHash = request.LatticePolicyHash,
|
||||
InputHashes = request.InputHashes ?? Array.Empty<string>(),
|
||||
ScoringConfigVersion = request.ScoringConfigVersion,
|
||||
EvidenceHashes = request.EvidenceHashes ?? Array.Empty<string>(),
|
||||
AdditionalContext = request.AdditionalContext ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var result = tokenGenerator.VerifyWithExpiration(parsedToken, tokenRequest);
|
||||
|
||||
var response = new VerifyTokenResponse(
|
||||
Valid: result == ReplayTokenVerificationResult.Valid,
|
||||
Result: result.ToString(),
|
||||
TokenValue: parsedToken.Value,
|
||||
Algorithm: parsedToken.Algorithm,
|
||||
Version: parsedToken.Version,
|
||||
GeneratedAt: parsedToken.GeneratedAt,
|
||||
ExpiresAt: parsedToken.ExpiresAt,
|
||||
IsExpired: parsedToken.IsExpired(),
|
||||
TimeToExpiration: parsedToken.GetTimeToExpiration());
|
||||
|
||||
return Task.FromResult<Results<Ok<VerifyTokenResponse>, ProblemHttpResult>>(TypedResults.Ok(response));
|
||||
})
|
||||
.WithName("VerifyReplayToken")
|
||||
.RequireAuthorization(ReplayReadPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// GET /v1/replay/tokens/{tokenValue} - Get token details (parse only)
|
||||
app.MapGet("/v1/replay/tokens/{tokenCanonical}", Task<Results<Ok<TokenInfoResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string tokenCanonical,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return Task.FromResult<Results<Ok<TokenInfoResponse>, NotFound, ProblemHttpResult>>(tenantProblem!);
|
||||
}
|
||||
|
||||
if (!ReplayToken.TryParse(tokenCanonical, out var token) || token is null)
|
||||
{
|
||||
return Task.FromResult<Results<Ok<TokenInfoResponse>, NotFound, ProblemHttpResult>>(TypedResults.NotFound());
|
||||
}
|
||||
|
||||
var response = new TokenInfoResponse(
|
||||
Canonical: token.Canonical,
|
||||
Value: token.Value,
|
||||
Algorithm: token.Algorithm,
|
||||
Version: token.Version,
|
||||
GeneratedAt: token.GeneratedAt,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
IsExpired: token.IsExpired(),
|
||||
TimeToExpiration: token.GetTimeToExpiration());
|
||||
|
||||
return Task.FromResult<Results<Ok<TokenInfoResponse>, NotFound, ProblemHttpResult>>(TypedResults.Ok(response));
|
||||
})
|
||||
.WithName("GetReplayToken")
|
||||
.RequireAuthorization(ReplayReadPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// GET /.well-known/openapi - OpenAPI specification
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context) =>
|
||||
{
|
||||
var spec = """
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Replay Token API
|
||||
version: "1.0"
|
||||
description: API for generating and verifying deterministic replay tokens
|
||||
paths:
|
||||
/v1/replay/tokens:
|
||||
post:
|
||||
summary: Generate a replay token
|
||||
operationId: GenerateReplayToken
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenerateTokenRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Token created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenerateTokenResponse'
|
||||
/v1/replay/tokens/verify:
|
||||
post:
|
||||
summary: Verify a replay token
|
||||
operationId: VerifyReplayToken
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerifyTokenRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Verification result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerifyTokenResponse'
|
||||
components:
|
||||
schemas:
|
||||
GenerateTokenRequest:
|
||||
type: object
|
||||
properties:
|
||||
feedManifests:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
rulesVersion:
|
||||
type: string
|
||||
rulesHash:
|
||||
type: string
|
||||
inputHashes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
withExpiration:
|
||||
type: boolean
|
||||
expirationMinutes:
|
||||
type: integer
|
||||
GenerateTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
canonical:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
generatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
VerifyTokenRequest:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
feedManifests:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
rulesVersion:
|
||||
type: string
|
||||
rulesHash:
|
||||
type: string
|
||||
inputHashes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
VerifyTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
result:
|
||||
type: string
|
||||
tokenValue:
|
||||
type: string
|
||||
isExpired:
|
||||
type: boolean
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
security:
|
||||
- bearerAuth: []
|
||||
""";
|
||||
|
||||
return Results.Text(spec, "application/yaml");
|
||||
})
|
||||
.WithName("ReplayOpenApiDocument")
|
||||
.Produces(StatusCodes.Status200OK);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool TryGetTenant(HttpContext httpContext, out ProblemHttpResult? problem, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
||||
{
|
||||
problem = TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = tenantValues.ToString();
|
||||
problem = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Request/Response models
|
||||
|
||||
public record GenerateTokenRequest(
|
||||
IReadOnlyList<string>? FeedManifests,
|
||||
string? RulesVersion,
|
||||
string? RulesHash,
|
||||
string? LatticePolicyVersion,
|
||||
string? LatticePolicyHash,
|
||||
IReadOnlyList<string>? InputHashes,
|
||||
string? ScoringConfigVersion,
|
||||
IReadOnlyList<string>? EvidenceHashes,
|
||||
IReadOnlyDictionary<string, string>? AdditionalContext,
|
||||
bool WithExpiration = true,
|
||||
int? ExpirationMinutes = null);
|
||||
|
||||
public record GenerateTokenResponse(
|
||||
string Canonical,
|
||||
string Value,
|
||||
DateTimeOffset GeneratedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string Algorithm,
|
||||
string Version);
|
||||
|
||||
public record VerifyTokenRequest(
|
||||
string Token,
|
||||
IReadOnlyList<string>? FeedManifests,
|
||||
string? RulesVersion,
|
||||
string? RulesHash,
|
||||
string? LatticePolicyVersion,
|
||||
string? LatticePolicyHash,
|
||||
IReadOnlyList<string>? InputHashes,
|
||||
string? ScoringConfigVersion,
|
||||
IReadOnlyList<string>? EvidenceHashes,
|
||||
IReadOnlyDictionary<string, string>? AdditionalContext);
|
||||
|
||||
public record VerifyTokenResponse(
|
||||
bool Valid,
|
||||
string Result,
|
||||
string TokenValue,
|
||||
string Algorithm,
|
||||
string Version,
|
||||
DateTimeOffset GeneratedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool IsExpired,
|
||||
TimeSpan? TimeToExpiration);
|
||||
|
||||
public record TokenInfoResponse(
|
||||
string Canonical,
|
||||
string Value,
|
||||
string Algorithm,
|
||||
string Version,
|
||||
DateTimeOffset GeneratedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool IsExpired,
|
||||
TimeSpan? TimeToExpiration);
|
||||
|
||||
// Configuration models
|
||||
|
||||
public class ReplayServiceOptions
|
||||
{
|
||||
public const string SectionName = "Replay";
|
||||
|
||||
public AuthorityConfig Authority { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AuthorityConfig
|
||||
{
|
||||
public string Issuer { get; set; } = "https://auth.stellaops.local";
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public string MetadataAddress { get; set; } = "https://auth.stellaops.local/.well-known/openid-configuration";
|
||||
public List<string> Audiences { get; set; } = new() { "stellaops-api" };
|
||||
public List<string> RequiredScopes { get; set; } = new() { "vuln.operate" };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Audit.ReplayToken\StellaOps.Audit.ReplayToken.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user