Restore live platform compatibility contracts
This commit is contained in:
215
src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs
Normal file
215
src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Signals.Hosting;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Routing;
|
||||
|
||||
namespace StellaOps.Signals;
|
||||
|
||||
internal static class CompatibilityApiV1Endpoints
|
||||
{
|
||||
private static readonly CompatibilitySignalRecord[] Signals =
|
||||
[
|
||||
new(
|
||||
"sig-001",
|
||||
"ci_build",
|
||||
"gitea",
|
||||
"completed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "build-agent-01",
|
||||
["runtime"] = "ebpf",
|
||||
["probeStatus"] = "healthy",
|
||||
["latencyMs"] = 41
|
||||
},
|
||||
"corr-001",
|
||||
"sha256:001",
|
||||
new[] { "update-runtime-health" },
|
||||
"2026-03-09T08:10:00Z",
|
||||
"2026-03-09T08:10:02Z",
|
||||
null),
|
||||
new(
|
||||
"sig-002",
|
||||
"ci_deploy",
|
||||
"internal",
|
||||
"processing",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "deploy-stage-02",
|
||||
["runtime"] = "etw",
|
||||
["probeStatus"] = "degraded",
|
||||
["latencyMs"] = 84
|
||||
},
|
||||
"corr-002",
|
||||
"sha256:002",
|
||||
new[] { "refresh-rollout-state" },
|
||||
"2026-03-09T08:12:00Z",
|
||||
null,
|
||||
null),
|
||||
new(
|
||||
"sig-003",
|
||||
"registry_push",
|
||||
"harbor",
|
||||
"failed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "registry-sync-01",
|
||||
["runtime"] = "dyld",
|
||||
["probeStatus"] = "failed",
|
||||
["latencyMs"] = 132
|
||||
},
|
||||
"corr-003",
|
||||
"sha256:003",
|
||||
new[] { "retry-mirror" },
|
||||
"2026-03-09T08:13:00Z",
|
||||
"2026-03-09T08:13:05Z",
|
||||
"Registry callback timed out."),
|
||||
new(
|
||||
"sig-004",
|
||||
"scan_complete",
|
||||
"internal",
|
||||
"completed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "scanner-03",
|
||||
["runtime"] = "ebpf",
|
||||
["probeStatus"] = "healthy",
|
||||
["latencyMs"] = 58
|
||||
},
|
||||
"corr-004",
|
||||
"sha256:004",
|
||||
new[] { "refresh-risk-snapshot" },
|
||||
"2026-03-09T08:16:00Z",
|
||||
"2026-03-09T08:16:01Z",
|
||||
null),
|
||||
new(
|
||||
"sig-005",
|
||||
"policy_eval",
|
||||
"internal",
|
||||
"received",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["host"] = "policy-runner-01",
|
||||
["runtime"] = "unknown",
|
||||
["probeStatus"] = "degraded",
|
||||
["latencyMs"] = 73
|
||||
},
|
||||
"corr-005",
|
||||
"sha256:005",
|
||||
new[] { "await-policy-evaluation" },
|
||||
"2026-03-09T08:18:00Z",
|
||||
null,
|
||||
null)
|
||||
];
|
||||
|
||||
public static IEndpointRouteBuilder MapSignalsCompatibilityEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/signals").RequireTenant();
|
||||
|
||||
group.MapGet("", (
|
||||
HttpContext context,
|
||||
SignalsOptions options,
|
||||
SignalsSealedModeMonitor sealedModeMonitor,
|
||||
string? type,
|
||||
string? status,
|
||||
string? provider,
|
||||
int? limit,
|
||||
string? cursor) =>
|
||||
{
|
||||
if (!Program.TryAuthorizeAny(context, [SignalsPolicies.Read, StellaOpsScopes.OrchRead], options.Authority.AllowAnonymousFallback, out var authFailure))
|
||||
{
|
||||
return authFailure ?? Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
||||
{
|
||||
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var filtered = ApplyFilters(type, status, provider);
|
||||
var offset = ParseCursor(cursor);
|
||||
var pageSize = Math.Clamp(limit ?? 50, 1, 200);
|
||||
var items = filtered.Skip(offset).Take(pageSize).ToArray();
|
||||
var nextCursor = offset + pageSize < filtered.Length ? (offset + pageSize).ToString() : null;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
total = filtered.Length,
|
||||
cursor = nextCursor
|
||||
});
|
||||
});
|
||||
|
||||
group.MapGet("/stats", (
|
||||
HttpContext context,
|
||||
SignalsOptions options,
|
||||
SignalsSealedModeMonitor sealedModeMonitor) =>
|
||||
{
|
||||
if (!Program.TryAuthorizeAny(context, [SignalsPolicies.Read, StellaOpsScopes.OrchRead], options.Authority.AllowAnonymousFallback, out var authFailure))
|
||||
{
|
||||
return authFailure ?? Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
||||
{
|
||||
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Results.Ok(BuildStats(Signals));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static CompatibilitySignalRecord[] ApplyFilters(string? type, string? status, string? provider) =>
|
||||
Signals
|
||||
.Where(signal => string.IsNullOrWhiteSpace(type) || string.Equals(signal.Type, type, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(signal => string.IsNullOrWhiteSpace(status) || string.Equals(signal.Status, status, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(signal => string.IsNullOrWhiteSpace(provider) || string.Equals(signal.Provider, provider, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
private static int ParseCursor(string? cursor) =>
|
||||
int.TryParse(cursor, out var offset) && offset >= 0 ? offset : 0;
|
||||
|
||||
private static object BuildStats(IReadOnlyCollection<CompatibilitySignalRecord> signals)
|
||||
{
|
||||
var byType = signals
|
||||
.GroupBy(signal => signal.Type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
var byStatus = signals
|
||||
.GroupBy(signal => signal.Status, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
var byProvider = signals
|
||||
.GroupBy(signal => signal.Provider, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
var successful = signals.Count(signal => string.Equals(signal.Status, "completed", StringComparison.OrdinalIgnoreCase));
|
||||
var latencySamples = signals
|
||||
.Select(signal => signal.Payload.TryGetValue("latencyMs", out var value) ? value : null)
|
||||
.OfType<int>()
|
||||
.ToArray();
|
||||
|
||||
return new
|
||||
{
|
||||
total = signals.Count,
|
||||
byType,
|
||||
byStatus,
|
||||
byProvider,
|
||||
lastHourCount = signals.Count,
|
||||
successRate = signals.Count == 0 ? 100 : Math.Round((successful / (double)signals.Count) * 100, 2),
|
||||
avgProcessingMs = latencySamples.Length == 0 ? 0 : Math.Round(latencySamples.Average(), 2)
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record CompatibilitySignalRecord(
|
||||
string Id,
|
||||
string Type,
|
||||
string Provider,
|
||||
string Status,
|
||||
IReadOnlyDictionary<string, object?> Payload,
|
||||
string? CorrelationId,
|
||||
string? ArtifactRef,
|
||||
IReadOnlyCollection<string> TriggeredActions,
|
||||
string ReceivedAt,
|
||||
string? ProcessedAt,
|
||||
string? Error);
|
||||
}
|
||||
@@ -1024,6 +1024,8 @@ signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
|
||||
}
|
||||
}).WithName("SignalsReachabilityRecompute");
|
||||
|
||||
StellaOps.Signals.CompatibilityApiV1Endpoints.MapSignalsCompatibilityEndpoints(app);
|
||||
|
||||
|
||||
await app.LoadTranslationsAsync();
|
||||
|
||||
@@ -1072,6 +1074,59 @@ internal partial class Program
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryAuthorizeAny(HttpContext httpContext, IReadOnlyCollection<string> requiredScopes, bool fallbackAllowed, out IResult? failure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(requiredScopes);
|
||||
|
||||
var scopes = requiredScopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (scopes.Length == 0)
|
||||
{
|
||||
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (httpContext.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
if (scopes.Any(scope => TokenScopeAuthorizer.HasScope(httpContext.User, scope)))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fallbackAllowed)
|
||||
{
|
||||
failure = Results.Unauthorized();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!httpContext.Request.Headers.TryGetValue("X-Scopes", out var scopesHeader) ||
|
||||
string.IsNullOrWhiteSpace(scopesHeader.ToString()))
|
||||
{
|
||||
failure = Results.Unauthorized();
|
||||
return false;
|
||||
}
|
||||
|
||||
var principal = HeaderScopeAuthorizer.CreatePrincipal(scopesHeader.ToString());
|
||||
if (scopes.Any(scope => HeaderScopeAuthorizer.HasScope(principal, scope)))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryEnsureSealedMode(SignalsSealedModeMonitor monitor, out IResult? failure)
|
||||
{
|
||||
if (!monitor.EnforcementEnabled)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Signals.Routing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public sealed class ProgramCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryAuthorizeAny_AllowsLegacyOrchReadTokenScope()
|
||||
{
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
User = CreatePrincipal(StellaOpsScopes.OrchRead)
|
||||
};
|
||||
|
||||
var authorized = Program.TryAuthorizeAny(
|
||||
context,
|
||||
[SignalsPolicies.Read, StellaOpsScopes.OrchRead],
|
||||
fallbackAllowed: false,
|
||||
out var failure);
|
||||
|
||||
Assert.True(authorized);
|
||||
Assert.Null(failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAuthorizeAny_AllowsHeaderFallbackForLegacyScope()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers["X-Scopes"] = StellaOpsScopes.OrchRead;
|
||||
|
||||
var authorized = Program.TryAuthorizeAny(
|
||||
context,
|
||||
[SignalsPolicies.Read, StellaOpsScopes.OrchRead],
|
||||
fallbackAllowed: true,
|
||||
out var failure);
|
||||
|
||||
Assert.True(authorized);
|
||||
Assert.Null(failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAuthorizeAny_RejectsWhenNoCompatibleScopeExists()
|
||||
{
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
User = CreatePrincipal(StellaOpsScopes.PolicyRead)
|
||||
};
|
||||
|
||||
var authorized = Program.TryAuthorizeAny(
|
||||
context,
|
||||
[SignalsPolicies.Read, StellaOpsScopes.OrchRead],
|
||||
fallbackAllowed: false,
|
||||
out var failure);
|
||||
|
||||
Assert.False(authorized);
|
||||
Assert.NotNull(failure);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string scope)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
[
|
||||
new Claim(StellaOpsClaimTypes.Scope, scope),
|
||||
new Claim(StellaOpsClaimTypes.ScopeItem, scope)
|
||||
], "Bearer");
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user