361 lines
12 KiB
C#
361 lines
12 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Policy.Engine.Services;
|
|
using StellaOps.Policy.RiskProfile.Overrides;
|
|
|
|
namespace StellaOps.Policy.Engine.Endpoints;
|
|
|
|
internal static class OverrideEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapOverrides(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
var group = endpoints.MapGroup("/api/risk/overrides")
|
|
.RequireAuthorization()
|
|
.WithTags("Risk Overrides");
|
|
|
|
group.MapPost("/", CreateOverride)
|
|
.WithName("CreateOverride")
|
|
.WithSummary("Create a new override with audit metadata.")
|
|
.Produces<OverrideResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapGet("/{overrideId}", GetOverride)
|
|
.WithName("GetOverride")
|
|
.WithSummary("Get an override by ID.")
|
|
.Produces<OverrideResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapDelete("/{overrideId}", DeleteOverride)
|
|
.WithName("DeleteOverride")
|
|
.WithSummary("Delete an override.")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/profile/{profileId}", ListProfileOverrides)
|
|
.WithName("ListProfileOverrides")
|
|
.WithSummary("List all overrides for a risk profile.")
|
|
.Produces<OverrideListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapPost("/validate", ValidateOverride)
|
|
.WithName("ValidateOverride")
|
|
.WithSummary("Validate an override for conflicts before creating.")
|
|
.Produces<OverrideValidationResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapPost("/{overrideId}:approve", ApproveOverride)
|
|
.WithName("ApproveOverride")
|
|
.WithSummary("Approve an override that requires review.")
|
|
.Produces<OverrideResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{overrideId}:disable", DisableOverride)
|
|
.WithName("DisableOverride")
|
|
.WithSummary("Disable an active override.")
|
|
.Produces<OverrideResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{overrideId}/history", GetOverrideHistory)
|
|
.WithName("GetOverrideHistory")
|
|
.WithSummary("Get application history for an override.")
|
|
.Produces<OverrideHistoryResponse>(StatusCodes.Status200OK);
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static IResult CreateOverride(
|
|
HttpContext context,
|
|
[FromBody] CreateOverrideRequest request,
|
|
OverrideService overrideService,
|
|
RiskProfileConfigurationService profileService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "ProfileId is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Reason))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Reason is required for audit purposes.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
// Verify profile exists
|
|
var profile = profileService.GetProfile(request.ProfileId);
|
|
if (profile == null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"Risk profile '{request.ProfileId}' was not found.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
// Validate for conflicts
|
|
var validation = overrideService.ValidateConflicts(request);
|
|
if (validation.HasConflicts)
|
|
{
|
|
var conflictDetails = string.Join("; ", validation.Conflicts.Select(c => c.Description));
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Override conflicts detected",
|
|
Detail = conflictDetails,
|
|
Status = StatusCodes.Status400BadRequest,
|
|
Extensions = { ["conflicts"] = validation.Conflicts }
|
|
});
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
try
|
|
{
|
|
var auditedOverride = overrideService.Create(request, actorId);
|
|
|
|
return Results.Created(
|
|
$"/api/risk/overrides/{auditedOverride.OverrideId}",
|
|
new OverrideResponse(auditedOverride, validation.Warnings));
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult GetOverride(
|
|
HttpContext context,
|
|
[FromRoute] string overrideId,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var auditedOverride = overrideService.Get(overrideId);
|
|
if (auditedOverride == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Override not found",
|
|
Detail = $"Override '{overrideId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
return Results.Ok(new OverrideResponse(auditedOverride, null));
|
|
}
|
|
|
|
private static IResult DeleteOverride(
|
|
HttpContext context,
|
|
[FromRoute] string overrideId,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (!overrideService.Delete(overrideId))
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Override not found",
|
|
Detail = $"Override '{overrideId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
return Results.NoContent();
|
|
}
|
|
|
|
private static IResult ListProfileOverrides(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromQuery] bool includeInactive,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var overrides = overrideService.ListByProfile(profileId, includeInactive);
|
|
|
|
return Results.Ok(new OverrideListResponse(profileId, overrides));
|
|
}
|
|
|
|
private static IResult ValidateOverride(
|
|
HttpContext context,
|
|
[FromBody] CreateOverrideRequest request,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request == null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Request body is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var validation = overrideService.ValidateConflicts(request);
|
|
|
|
return Results.Ok(new OverrideValidationResponse(validation));
|
|
}
|
|
|
|
private static IResult ApproveOverride(
|
|
HttpContext context,
|
|
[FromRoute] string overrideId,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
try
|
|
{
|
|
var auditedOverride = overrideService.Approve(overrideId, actorId ?? "system");
|
|
if (auditedOverride == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Override not found",
|
|
Detail = $"Override '{overrideId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
return Results.Ok(new OverrideResponse(auditedOverride, null));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Approval failed",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult DisableOverride(
|
|
HttpContext context,
|
|
[FromRoute] string overrideId,
|
|
[FromQuery] string? reason,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
var auditedOverride = overrideService.Disable(overrideId, actorId ?? "system", reason);
|
|
if (auditedOverride == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Override not found",
|
|
Detail = $"Override '{overrideId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
return Results.Ok(new OverrideResponse(auditedOverride, null));
|
|
}
|
|
|
|
private static IResult GetOverrideHistory(
|
|
HttpContext context,
|
|
[FromRoute] string overrideId,
|
|
[FromQuery] int limit,
|
|
OverrideService overrideService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var effectiveLimit = limit > 0 ? limit : 100;
|
|
var history = overrideService.GetApplicationHistory(overrideId, effectiveLimit);
|
|
|
|
return Results.Ok(new OverrideHistoryResponse(overrideId, history));
|
|
}
|
|
|
|
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 Response DTOs
|
|
|
|
internal sealed record OverrideResponse(
|
|
AuditedOverride Override,
|
|
IReadOnlyList<string>? Warnings);
|
|
|
|
internal sealed record OverrideListResponse(
|
|
string ProfileId,
|
|
IReadOnlyList<AuditedOverride> Overrides);
|
|
|
|
internal sealed record OverrideValidationResponse(OverrideConflictValidation Validation);
|
|
|
|
internal sealed record OverrideHistoryResponse(
|
|
string OverrideId,
|
|
IReadOnlyList<OverrideApplicationRecord> History);
|
|
|
|
#endregion
|