- 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.
426 lines
14 KiB
C#
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
|