Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -1,150 +1,150 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyCompilationEndpoints
|
||||
{
|
||||
private const string CompileRoute = "/api/policy/policies/{policyId}/versions/{version}:compile";
|
||||
|
||||
public static IEndpointRouteBuilder MapPolicyCompilation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(CompileRoute, CompilePolicy)
|
||||
.WithName("CompilePolicy")
|
||||
.WithSummary("Compile and lint a policy DSL document.")
|
||||
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
|
||||
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(); // scopes enforced by policy middleware.
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CompilePolicy(
|
||||
[FromRoute] string policyId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyCompileRequest request,
|
||||
PolicyCompilationService compilationService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Request body missing.", policyId, version));
|
||||
}
|
||||
|
||||
var result = compilationService.Compile(request);
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(BuildProblem("ERR_POL_001", "Policy compilation failed.", policyId, version, result.Diagnostics));
|
||||
}
|
||||
|
||||
var response = new PolicyCompileResponse(
|
||||
result.Digest!,
|
||||
result.Statistics ?? new PolicyCompilationStatistics(0, ImmutableDictionary<string, int>.Empty),
|
||||
MapComplexity(result.Complexity),
|
||||
result.DurationMilliseconds,
|
||||
ConvertDiagnostics(result.Diagnostics));
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static PolicyProblemDetails BuildProblem(string code, string message, string policyId, int version, ImmutableArray<PolicyIssue>? diagnostics = null)
|
||||
{
|
||||
var problem = new PolicyProblemDetails
|
||||
{
|
||||
Code = code,
|
||||
Title = "Policy compilation error",
|
||||
Detail = message,
|
||||
PolicyId = policyId,
|
||||
PolicyVersion = version
|
||||
};
|
||||
|
||||
if (diagnostics is { Length: > 0 } diag)
|
||||
{
|
||||
problem.Diagnostics = diag;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyDiagnosticDto> ConvertDiagnostics(ImmutableArray<PolicyIssue> issues)
|
||||
{
|
||||
if (issues.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PolicyDiagnosticDto>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyDiagnosticDto>(issues.Length);
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (issue.Severity != PolicyIssueSeverity.Warning)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new PolicyDiagnosticDto(issue.Code, issue.Message, issue.Path));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyComplexityReportDto? MapComplexity(PolicyComplexityReport? report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PolicyComplexityReportDto(
|
||||
report.Score,
|
||||
report.RuleCount,
|
||||
report.ActionCount,
|
||||
report.ExpressionCount,
|
||||
report.InvocationCount,
|
||||
report.MemberAccessCount,
|
||||
report.IdentifierCount,
|
||||
report.LiteralCount,
|
||||
report.MaxExpressionDepth,
|
||||
report.ProfileCount,
|
||||
report.ProfileBindingCount,
|
||||
report.ConditionCount,
|
||||
report.ListItemCount);
|
||||
}
|
||||
|
||||
private sealed class PolicyProblemDetails : ProblemDetails
|
||||
{
|
||||
public string Code { get; set; } = "ERR_POL_001";
|
||||
|
||||
public string? PolicyId { get; set; }
|
||||
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
public ImmutableArray<PolicyIssue> Diagnostics { get; set; } = ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileResponse(
|
||||
string Digest,
|
||||
PolicyCompilationStatistics Statistics,
|
||||
PolicyComplexityReportDto? Complexity,
|
||||
long DurationMilliseconds,
|
||||
ImmutableArray<PolicyDiagnosticDto> Warnings);
|
||||
|
||||
internal sealed record PolicyDiagnosticDto(string Code, string Message, string Path);
|
||||
|
||||
internal sealed record PolicyComplexityReportDto(
|
||||
double Score,
|
||||
int RuleCount,
|
||||
int ActionCount,
|
||||
int ExpressionCount,
|
||||
int InvocationCount,
|
||||
int MemberAccessCount,
|
||||
int IdentifierCount,
|
||||
int LiteralCount,
|
||||
int MaxExpressionDepth,
|
||||
int ProfileCount,
|
||||
int ProfileBindingCount,
|
||||
int ConditionCount,
|
||||
int ListItemCount);
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyPackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/policy/packs")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
group.MapPost(string.Empty, CreatePack)
|
||||
.WithName("CreatePolicyPack")
|
||||
.WithSummary("Create a new policy pack container.")
|
||||
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapGet(string.Empty, ListPacks)
|
||||
.WithName("ListPolicyPacks")
|
||||
.WithSummary("List policy packs for the current tenant.")
|
||||
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class PolicyPackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/policy/packs")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Packs");
|
||||
|
||||
group.MapPost(string.Empty, CreatePack)
|
||||
.WithName("CreatePolicyPack")
|
||||
.WithSummary("Create a new policy pack container.")
|
||||
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapGet(string.Empty, ListPacks)
|
||||
.WithName("ListPolicyPacks")
|
||||
.WithSummary("List policy packs for the current tenant.")
|
||||
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/{packId}/revisions", CreateRevision)
|
||||
.WithName("CreatePolicyRevision")
|
||||
.WithSummary("Create or update policy revision metadata.")
|
||||
@@ -49,149 +49,149 @@ internal static class PolicyPackEndpoints
|
||||
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
|
||||
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePolicyPackRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
||||
? $"pack-{Guid.NewGuid():n}"
|
||||
: request.PackId.Trim();
|
||||
|
||||
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var dto = PolicyPackMapper.ToDto(pack);
|
||||
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromBody] CreatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationSettings activationSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid status",
|
||||
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
||||
|
||||
var revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
requiresTwoPersonApproval,
|
||||
request.InitialStatus,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
||||
PolicyPackMapper.ToDto(packId, revision));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ActivateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] ActivatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationAuditor auditor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
if (actorId is null)
|
||||
{
|
||||
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var result = await repository.RecordActivationAsync(
|
||||
packId,
|
||||
version,
|
||||
actorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Comment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
||||
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
||||
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePolicyPackRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
||||
? $"pack-{Guid.NewGuid():n}"
|
||||
: request.PackId.Trim();
|
||||
|
||||
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var dto = PolicyPackMapper.ToDto(pack);
|
||||
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
IPolicyPackRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
||||
return Results.Ok(summaries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromBody] CreatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationSettings activationSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid status",
|
||||
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
||||
|
||||
var revision = await repository.UpsertRevisionAsync(
|
||||
packId,
|
||||
request.Version ?? 0,
|
||||
requiresTwoPersonApproval,
|
||||
request.InitialStatus,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
||||
PolicyPackMapper.ToDto(packId, revision));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ActivateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] ActivatePolicyRevisionRequest request,
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyActivationAuditor auditor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
if (actorId is null)
|
||||
{
|
||||
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var result = await repository.RecordActivationAsync(
|
||||
packId,
|
||||
version,
|
||||
actorId,
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Comment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
||||
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
|
||||
@@ -199,29 +199,29 @@ internal static class PolicyPackEndpoints
|
||||
Title = "Policy pack not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Policy revision not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Revision not approved",
|
||||
Detail = "Only approved revisions may be activated.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval already recorded",
|
||||
Detail = "This approver has already approved activation.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
||||
$"/api/policy/packs/{packId}/revisions/{version}",
|
||||
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
_ => Results.BadRequest(new ProblemDetails
|
||||
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Policy revision not found",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
}),
|
||||
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Revision not approved",
|
||||
Detail = "Only approved revisions may be activated.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval already recorded",
|
||||
Detail = "This approver has already approved activation.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
}),
|
||||
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
||||
$"/api/policy/packs/{packId}/revisions/{version}",
|
||||
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
||||
_ => Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Activation failed",
|
||||
Detail = "Unknown activation result.",
|
||||
@@ -323,57 +323,57 @@ internal static class PolicyPackEndpoints
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyPackMapper
|
||||
{
|
||||
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
||||
|
||||
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
||||
|
||||
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
||||
=> new(
|
||||
packId,
|
||||
revision.Version,
|
||||
revision.Status.ToString(),
|
||||
revision.RequiresTwoPersonApproval,
|
||||
revision.CreatedAt,
|
||||
revision.ActivatedAt,
|
||||
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
||||
}
|
||||
|
||||
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
||||
|
||||
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
||||
|
||||
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
||||
|
||||
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyPackMapper
|
||||
{
|
||||
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
||||
|
||||
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
||||
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
||||
|
||||
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
||||
=> new(
|
||||
packId,
|
||||
revision.Version,
|
||||
revision.Status.ToString(),
|
||||
revision.RequiresTwoPersonApproval,
|
||||
revision.CreatedAt,
|
||||
revision.ActivatedAt,
|
||||
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
||||
}
|
||||
|
||||
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
||||
|
||||
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
||||
|
||||
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
||||
|
||||
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
||||
|
||||
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
||||
|
||||
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user