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; /// /// Policy conflict detection and resolution endpoints. /// Conflicts track policy rule overlaps and inconsistencies. /// 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(StatusCodes.Status200OK); group.MapGet("/{conflictId:guid}", GetConflict) .WithName("GetPolicyConflict") .WithSummary("Get a specific policy conflict by ID.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/by-type/{conflictType}", GetConflictsByType) .WithName("GetPolicyConflictsByType") .WithSummary("Get conflicts filtered by type.") .Produces(StatusCodes.Status200OK); group.MapGet("/stats/by-severity", GetConflictStatsBySeverity) .WithName("GetPolicyConflictStatsBySeverity") .WithSummary("Get open conflict counts grouped by severity.") .Produces(StatusCodes.Status200OK); group.MapPost(string.Empty, CreateConflict) .WithName("CreatePolicyConflict") .WithSummary("Report a new policy conflict.") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/{conflictId:guid}:resolve", ResolveConflict) .WithName("ResolvePolicyConflict") .WithSummary("Resolve an open conflict with a resolution description.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); group.MapPost("/{conflictId:guid}:dismiss", DismissConflict) .WithName("DismissPolicyConflict") .WithSummary("Dismiss an open conflict without resolution.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); return endpoints; } private static async Task 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 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 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 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 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 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 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 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 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