up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

@@ -0,0 +1,524 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class RiskProfileEndpoints
{
public static IEndpointRouteBuilder MapRiskProfiles(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/risk/profiles")
.RequireAuthorization()
.WithTags("Risk Profiles");
group.MapGet(string.Empty, ListProfiles)
.WithName("ListRiskProfiles")
.WithSummary("List all available risk profiles.")
.Produces<RiskProfileListResponse>(StatusCodes.Status200OK);
group.MapGet("/{profileId}", GetProfile)
.WithName("GetRiskProfile")
.WithSummary("Get a risk profile by ID.")
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/versions", ListVersions)
.WithName("ListRiskProfileVersions")
.WithSummary("List all versions of a risk profile.")
.Produces<RiskProfileVersionListResponse>(StatusCodes.Status200OK);
group.MapGet("/{profileId}/versions/{version}", GetVersion)
.WithName("GetRiskProfileVersion")
.WithSummary("Get a specific version of a risk profile.")
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost(string.Empty, CreateProfile)
.WithName("CreateRiskProfile")
.WithSummary("Create a new risk profile version in draft status.")
.Produces<RiskProfileResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{profileId}/versions/{version}:activate", ActivateProfile)
.WithName("ActivateRiskProfile")
.WithSummary("Activate a draft risk profile, making it available for use.")
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/{profileId}/versions/{version}:deprecate", DeprecateProfile)
.WithName("DeprecateRiskProfile")
.WithSummary("Deprecate an active risk profile.")
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/{profileId}/versions/{version}:archive", ArchiveProfile)
.WithName("ArchiveRiskProfile")
.WithSummary("Archive a risk profile, removing it from active use.")
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/events", GetProfileEvents)
.WithName("GetRiskProfileEvents")
.WithSummary("Get lifecycle events for a risk profile.")
.Produces<RiskProfileEventListResponse>(StatusCodes.Status200OK);
group.MapPost("/compare", CompareProfiles)
.WithName("CompareRiskProfiles")
.WithSummary("Compare two risk profile versions and list differences.")
.Produces<RiskProfileComparisonResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/{profileId}/hash", GetProfileHash)
.WithName("GetRiskProfileHash")
.WithSummary("Get the deterministic hash of a risk profile.")
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static IResult ListProfiles(
HttpContext context,
RiskProfileConfigurationService profileService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var ids = profileService.GetProfileIds();
var profiles = ids
.Select(id => profileService.GetProfile(id))
.Where(p => p != null)
.Select(p => new RiskProfileSummary(p!.Id, p.Version, p.Description))
.ToList();
return Results.Ok(new RiskProfileListResponse(profiles));
}
private static IResult GetProfile(
HttpContext context,
[FromRoute] string profileId,
RiskProfileConfigurationService profileService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var profile = profileService.GetProfile(profileId);
if (profile == null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = $"Risk profile '{profileId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
var hash = profileService.ComputeHash(profile);
return Results.Ok(new RiskProfileResponse(profile, hash));
}
private static IResult ListVersions(
HttpContext context,
[FromRoute] string profileId,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var versions = lifecycleService.GetAllVersions(profileId);
return Results.Ok(new RiskProfileVersionListResponse(profileId, versions));
}
private static IResult GetVersion(
HttpContext context,
[FromRoute] string profileId,
[FromRoute] string version,
RiskProfileConfigurationService profileService,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var versionInfo = lifecycleService.GetVersionInfo(profileId, version);
if (versionInfo == null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Version not found",
Detail = $"Risk profile '{profileId}' version '{version}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
var profile = profileService.GetProfile(profileId);
if (profile == null || profile.Version != version)
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = $"Risk profile '{profileId}' version '{version}' content not found.",
Status = StatusCodes.Status404NotFound
});
}
var hash = profileService.ComputeHash(profile);
return Results.Ok(new RiskProfileResponse(profile, hash, versionInfo));
}
private static IResult CreateProfile(
HttpContext context,
[FromBody] CreateRiskProfileRequest request,
RiskProfileConfigurationService profileService,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
if (request?.Profile == null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Profile definition is required.",
Status = StatusCodes.Status400BadRequest
});
}
var actorId = ResolveActorId(context);
try
{
var profile = request.Profile;
profileService.RegisterProfile(profile);
var versionInfo = lifecycleService.CreateVersion(profile, actorId);
var hash = profileService.ComputeHash(profile);
return Results.Created(
$"/api/risk/profiles/{profile.Id}/versions/{profile.Version}",
new RiskProfileResponse(profile, hash, versionInfo));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Profile creation failed",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static IResult ActivateProfile(
HttpContext context,
[FromRoute] string profileId,
[FromRoute] string version,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
if (scopeResult is not null)
{
return scopeResult;
}
var actorId = ResolveActorId(context);
try
{
var versionInfo = lifecycleService.Activate(profileId, version, actorId);
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
}
catch (InvalidOperationException ex)
{
if (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
return Results.BadRequest(new ProblemDetails
{
Title = "Activation failed",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static IResult DeprecateProfile(
HttpContext context,
[FromRoute] string profileId,
[FromRoute] string version,
[FromBody] DeprecateRiskProfileRequest? request,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
var actorId = ResolveActorId(context);
try
{
var versionInfo = lifecycleService.Deprecate(
profileId,
version,
request?.SuccessorVersion,
request?.Reason,
actorId);
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
}
catch (InvalidOperationException ex)
{
if (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
return Results.BadRequest(new ProblemDetails
{
Title = "Deprecation failed",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static IResult ArchiveProfile(
HttpContext context,
[FromRoute] string profileId,
[FromRoute] string version,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
var actorId = ResolveActorId(context);
try
{
var versionInfo = lifecycleService.Archive(profileId, version, actorId);
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
}
private static IResult GetProfileEvents(
HttpContext context,
[FromRoute] string profileId,
[FromQuery] int limit,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var effectiveLimit = limit > 0 ? limit : 100;
var events = lifecycleService.GetEvents(profileId, effectiveLimit);
return Results.Ok(new RiskProfileEventListResponse(profileId, events));
}
private static IResult CompareProfiles(
HttpContext context,
[FromBody] CompareRiskProfilesRequest request,
RiskProfileConfigurationService profileService,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
if (request == null ||
string.IsNullOrWhiteSpace(request.FromProfileId) ||
string.IsNullOrWhiteSpace(request.FromVersion) ||
string.IsNullOrWhiteSpace(request.ToProfileId) ||
string.IsNullOrWhiteSpace(request.ToVersion))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Both from and to profile IDs and versions are required.",
Status = StatusCodes.Status400BadRequest
});
}
var fromProfile = profileService.GetProfile(request.FromProfileId);
var toProfile = profileService.GetProfile(request.ToProfileId);
if (fromProfile == null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Profile not found",
Detail = $"From profile '{request.FromProfileId}' was not found.",
Status = StatusCodes.Status400BadRequest
});
}
if (toProfile == null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Profile not found",
Detail = $"To profile '{request.ToProfileId}' was not found.",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var comparison = lifecycleService.CompareVersions(fromProfile, toProfile);
return Results.Ok(new RiskProfileComparisonResponse(comparison));
}
catch (ArgumentException ex)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Comparison failed",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static IResult GetProfileHash(
HttpContext context,
[FromRoute] string profileId,
[FromQuery] bool contentOnly,
RiskProfileConfigurationService profileService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var profile = profileService.GetProfile(profileId);
if (profile == null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Profile not found",
Detail = $"Risk profile '{profileId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
var hash = contentOnly
? profileService.ComputeContentHash(profile)
: profileService.ComputeHash(profile);
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst(ClaimTypes.Upn)?.Value
?? user?.FindFirst("sub")?.Value;
if (!string.IsNullOrWhiteSpace(actor))
{
return actor;
}
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
{
return header.ToString();
}
return null;
}
}
#region Request/Response DTOs
internal sealed record RiskProfileListResponse(IReadOnlyList<RiskProfileSummary> Profiles);
internal sealed record RiskProfileSummary(string ProfileId, string Version, string? Description);
internal sealed record RiskProfileResponse(
RiskProfileModel Profile,
string Hash,
RiskProfileVersionInfo? VersionInfo = null);
internal sealed record RiskProfileVersionListResponse(
string ProfileId,
IReadOnlyList<RiskProfileVersionInfo> Versions);
internal sealed record RiskProfileVersionInfoResponse(RiskProfileVersionInfo VersionInfo);
internal sealed record RiskProfileEventListResponse(
string ProfileId,
IReadOnlyList<RiskProfileLifecycleEvent> Events);
internal sealed record RiskProfileComparisonResponse(RiskProfileVersionComparison Comparison);
internal sealed record RiskProfileHashResponse(
string ProfileId,
string Version,
string Hash,
bool ContentOnly);
internal sealed record CreateRiskProfileRequest(RiskProfileModel Profile);
internal sealed record DeprecateRiskProfileRequest(string? SuccessorVersion, string? Reason);
internal sealed record CompareRiskProfilesRequest(
string FromProfileId,
string FromVersion,
string ToProfileId,
string ToVersion);
#endregion

View File

@@ -0,0 +1,121 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using StellaOps.Policy.RiskProfile.Schema;
namespace StellaOps.Policy.Engine.Endpoints;
internal static class RiskProfileSchemaEndpoints
{
private const string JsonSchemaMediaType = "application/schema+json";
public static IEndpointRouteBuilder MapRiskProfileSchema(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/.well-known/risk-profile-schema", GetSchema)
.WithName("GetRiskProfileSchema")
.WithSummary("Get the JSON Schema for risk profile definitions.")
.WithTags("Schema Discovery")
.Produces<string>(StatusCodes.Status200OK, contentType: JsonSchemaMediaType)
.Produces(StatusCodes.Status304NotModified)
.AllowAnonymous();
endpoints.MapPost("/api/risk/schema/validate", ValidateProfile)
.WithName("ValidateRiskProfile")
.WithSummary("Validate a risk profile document against the schema.")
.WithTags("Schema Validation")
.Produces<RiskProfileValidationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
return endpoints;
}
private static IResult GetSchema(HttpContext context)
{
var schemaText = RiskProfileSchemaProvider.GetSchemaText();
var etag = RiskProfileSchemaProvider.GetETag();
var version = RiskProfileSchemaProvider.GetSchemaVersion();
context.Response.Headers[HeaderNames.ETag] = etag;
context.Response.Headers[HeaderNames.CacheControl] = "public, max-age=86400";
context.Response.Headers["X-StellaOps-Schema-Version"] = version;
var ifNoneMatch = context.Request.Headers[HeaderNames.IfNoneMatch].ToString();
if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch.Contains(etag.Trim('"')))
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
return Results.Text(schemaText, JsonSchemaMediaType);
}
private static IResult ValidateProfile(
HttpContext context,
[FromBody] JsonElement profileDocument)
{
if (profileDocument.ValueKind == JsonValueKind.Undefined)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Profile document is required.",
Status = StatusCodes.Status400BadRequest
});
}
var schema = RiskProfileSchemaProvider.GetSchema();
var jsonText = profileDocument.GetRawText();
var result = schema.Evaluate(System.Text.Json.Nodes.JsonNode.Parse(jsonText));
var issues = new List<RiskProfileValidationIssue>();
if (!result.IsValid)
{
CollectValidationIssues(result, issues);
}
return Results.Ok(new RiskProfileValidationResponse(
IsValid: result.IsValid,
SchemaVersion: RiskProfileSchemaProvider.GetSchemaVersion(),
Issues: issues));
}
private static void CollectValidationIssues(
Json.Schema.EvaluationResults results,
List<RiskProfileValidationIssue> issues,
string path = "")
{
if (results.Errors is not null)
{
foreach (var (key, message) in results.Errors)
{
var instancePath = results.InstanceLocation?.ToString() ?? path;
issues.Add(new RiskProfileValidationIssue(
Path: instancePath,
Error: key,
Message: message));
}
}
if (results.Details is not null)
{
foreach (var detail in results.Details)
{
if (!detail.IsValid)
{
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
}
}
}
}
}
internal sealed record RiskProfileValidationResponse(
bool IsValid,
string SchemaVersion,
IReadOnlyList<RiskProfileValidationIssue> Issues);
internal sealed record RiskProfileValidationIssue(
string Path,
string Error,
string Message);