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.
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
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
|
||||
Reference in New Issue
Block a user