Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/Endpoints/ConflictEndpoints.cs
StellaOps Bot 175b750e29 Implement InMemory Transport Layer for StellaOps Router
- Added InMemoryTransportOptions class for configuration settings including timeouts and latency.
- Developed InMemoryTransportServer class to handle connections, frame processing, and event management.
- Created ServiceCollectionExtensions for easy registration of InMemory transport services.
- Established project structure and dependencies for InMemory transport library.
- Implemented comprehensive unit tests for endpoint discovery, connection management, request/response flow, and streaming capabilities.
- Ensured proper handling of cancellation, heartbeat, and hello frames within the transport layer.
2025-12-05 01:00:10 +02:00

426 lines
14 KiB
C#

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Policy conflict detection and resolution endpoints.
/// Conflicts track policy rule overlaps and inconsistencies.
/// </summary>
internal static class ConflictEndpoints
{
public static IEndpointRouteBuilder MapConflictsApi(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/conflicts")
.RequireAuthorization()
.WithTags("Policy Conflicts");
group.MapGet(string.Empty, ListOpenConflicts)
.WithName("ListOpenPolicyConflicts")
.WithSummary("List open policy conflicts sorted by severity.")
.Produces<ConflictListResponse>(StatusCodes.Status200OK);
group.MapGet("/{conflictId:guid}", GetConflict)
.WithName("GetPolicyConflict")
.WithSummary("Get a specific policy conflict by ID.")
.Produces<ConflictResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/by-type/{conflictType}", GetConflictsByType)
.WithName("GetPolicyConflictsByType")
.WithSummary("Get conflicts filtered by type.")
.Produces<ConflictListResponse>(StatusCodes.Status200OK);
group.MapGet("/stats/by-severity", GetConflictStatsBySeverity)
.WithName("GetPolicyConflictStatsBySeverity")
.WithSummary("Get open conflict counts grouped by severity.")
.Produces<ConflictStatsResponse>(StatusCodes.Status200OK);
group.MapPost(string.Empty, CreateConflict)
.WithName("CreatePolicyConflict")
.WithSummary("Report a new policy conflict.")
.Produces<ConflictResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{conflictId:guid}:resolve", ResolveConflict)
.WithName("ResolvePolicyConflict")
.WithSummary("Resolve an open conflict with a resolution description.")
.Produces<ConflictActionResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/{conflictId:guid}:dismiss", DismissConflict)
.WithName("DismissPolicyConflict")
.WithSummary("Dismiss an open conflict without resolution.")
.Produces<ConflictActionResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
return endpoints;
}
private static async Task<IResult> ListOpenConflicts(
HttpContext context,
[FromQuery] int limit,
[FromQuery] int offset,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var effectiveLimit = limit > 0 ? limit : 100;
var effectiveOffset = offset > 0 ? offset : 0;
var conflicts = await repository.GetOpenAsync(tenantId, effectiveLimit, effectiveOffset, cancellationToken)
.ConfigureAwait(false);
var items = conflicts.Select(c => new ConflictSummary(
c.Id,
c.ConflictType,
c.Severity,
c.Status,
c.LeftRuleId,
c.RightRuleId,
c.AffectedScope,
c.Description,
c.CreatedAt
)).ToList();
return Results.Ok(new ConflictListResponse(items, effectiveLimit, effectiveOffset));
}
private static async Task<IResult> GetConflict(
HttpContext context,
[FromRoute] Guid conflictId,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var conflict = await repository.GetByIdAsync(tenantId, conflictId, cancellationToken)
.ConfigureAwait(false);
if (conflict is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Conflict not found",
Detail = $"Policy conflict '{conflictId}' was not found.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ConflictResponse(conflict));
}
private static async Task<IResult> GetConflictsByType(
HttpContext context,
[FromRoute] string conflictType,
[FromQuery] string? status,
[FromQuery] int limit,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var effectiveLimit = limit > 0 ? limit : 100;
var conflicts = await repository.GetByTypeAsync(tenantId, conflictType, status, effectiveLimit, cancellationToken)
.ConfigureAwait(false);
var items = conflicts.Select(c => new ConflictSummary(
c.Id,
c.ConflictType,
c.Severity,
c.Status,
c.LeftRuleId,
c.RightRuleId,
c.AffectedScope,
c.Description,
c.CreatedAt
)).ToList();
return Results.Ok(new ConflictListResponse(items, effectiveLimit, 0));
}
private static async Task<IResult> GetConflictStatsBySeverity(
HttpContext context,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var stats = await repository.CountOpenBySeverityAsync(tenantId, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new ConflictStatsResponse(stats));
}
private static async Task<IResult> CreateConflict(
HttpContext context,
[FromBody] CreateConflictRequest request,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var actorId = ResolveActorId(context);
var entity = new ConflictEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
ConflictType = request.ConflictType,
Severity = request.Severity,
Status = "open",
LeftRuleId = request.LeftRuleId,
RightRuleId = request.RightRuleId,
AffectedScope = request.AffectedScope,
Description = request.Description,
Metadata = request.Metadata ?? "{}",
CreatedBy = actorId
};
try
{
var created = await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/policy/conflicts/{created.Id}", new ConflictResponse(created));
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Failed to create conflict",
Detail = ex.Message,
Status = StatusCodes.Status400BadRequest
});
}
}
private static async Task<IResult> ResolveConflict(
HttpContext context,
[FromRoute] Guid conflictId,
[FromBody] ResolveConflictRequest request,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var actorId = ResolveActorId(context) ?? "system";
if (string.IsNullOrWhiteSpace(request.Resolution))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Resolution required",
Detail = "A resolution description is required to resolve a conflict.",
Status = StatusCodes.Status400BadRequest
});
}
var resolved = await repository.ResolveAsync(tenantId, conflictId, request.Resolution, actorId, cancellationToken)
.ConfigureAwait(false);
if (!resolved)
{
return Results.NotFound(new ProblemDetails
{
Title = "Conflict not found or already resolved",
Detail = $"Policy conflict '{conflictId}' was not found or is not in open status.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ConflictActionResponse(conflictId, "resolved", actorId));
}
private static async Task<IResult> DismissConflict(
HttpContext context,
[FromRoute] Guid conflictId,
IConflictRepository repository,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
if (scopeResult is not null)
{
return scopeResult;
}
var tenantId = ResolveTenantId(context);
if (string.IsNullOrEmpty(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant required",
Detail = "Tenant ID must be provided.",
Status = StatusCodes.Status400BadRequest
});
}
var actorId = ResolveActorId(context) ?? "system";
var dismissed = await repository.DismissAsync(tenantId, conflictId, actorId, cancellationToken)
.ConfigureAwait(false);
if (!dismissed)
{
return Results.NotFound(new ProblemDetails
{
Title = "Conflict not found or already resolved",
Detail = $"Policy conflict '{conflictId}' was not found or is not in open status.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ConflictActionResponse(conflictId, "dismissed", actorId));
}
private static string? ResolveTenantId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) &&
!string.IsNullOrWhiteSpace(tenantHeader))
{
return tenantHeader.ToString();
}
return context.User?.FindFirst("tenant_id")?.Value;
}
private static string? ResolveActorId(HttpContext context)
{
var user = context.User;
return user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? user?.FindFirst("sub")?.Value;
}
}
#region Request/Response DTOs
internal sealed record ConflictListResponse(
IReadOnlyList<ConflictSummary> Conflicts,
int Limit,
int Offset);
internal sealed record ConflictSummary(
Guid Id,
string ConflictType,
string Severity,
string Status,
string? LeftRuleId,
string? RightRuleId,
string? AffectedScope,
string Description,
DateTimeOffset CreatedAt);
internal sealed record ConflictResponse(ConflictEntity Conflict);
internal sealed record ConflictStatsResponse(Dictionary<string, int> CountBySeverity);
internal sealed record ConflictActionResponse(Guid ConflictId, string Action, string ActorId);
internal sealed record CreateConflictRequest(
string ConflictType,
string Severity,
string? LeftRuleId,
string? RightRuleId,
string? AffectedScope,
string Description,
string? Metadata);
internal sealed record ResolveConflictRequest(string Resolution);
#endregion