Restore live platform compatibility contracts

This commit is contained in:
master
2026-03-10 01:37:24 +02:00
parent 6b7168ca3c
commit afb9711e61
15 changed files with 1790 additions and 27 deletions

View File

@@ -244,6 +244,99 @@ public class StellaOpsScopeAuthorizationHandlerTests
Assert.Equal("true", GetPropertyValue(record, "principal.authenticated"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Succeeds_WhenAnyScopeRequirementMatchesOneScope()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.53"));
var requirement = new StellaOpsScopeRequirement(
new[] { "quota.read", StellaOpsScopes.OrchQuota },
requireAllScopes: false);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-quota")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.OrchQuota })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopeConfigured_AndAnyScopeRequirementMatchesAlternateScope()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add("quota.read");
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.55"));
var requirement = new StellaOpsScopeRequirement(
new[] { "quota.read", StellaOpsScopes.OrchQuota },
requireAllScopes: false);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-quota")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.OrchQuota })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Fails_WhenAnyScopeRequirementMatchesNone()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.54"));
var requirement = new StellaOpsScopeRequirement(
new[] { "quota.read", StellaOpsScopes.OrchQuota },
requireAllScopes: false);
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-quota")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("Required scopes not granted.", record.Reason);
Assert.Equal("orch:quota quota.read", GetPropertyValue(record, "resource.scopes.missing"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HandleRequirement_Fails_WhenIncidentAuthTimeMissing()

View File

@@ -33,6 +33,25 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
return builder;
}
/// <summary>
/// Requires that any one of the specified StellaOps scopes be present.
/// </summary>
public static AuthorizationPolicyBuilder RequireAnyStellaOpsScopes(
this AuthorizationPolicyBuilder builder,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(builder);
if (!builder.AuthenticationSchemes.Contains(StellaOpsAuthenticationDefaults.AuthenticationScheme))
{
builder.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
}
var requirement = new StellaOpsScopeRequirement(scopes, requireAllScopes: false);
builder.AddRequirements(requirement);
return builder;
}
/// <summary>
/// Registers a named policy that enforces the provided scopes.
/// </summary>
@@ -51,6 +70,24 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions
});
}
/// <summary>
/// Registers a named policy that is satisfied when any listed scope is granted.
/// </summary>
public static void AddStellaOpsAnyScopePolicy(
this AuthorizationOptions options,
string policyName,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(policyName);
options.AddPolicy(policyName, policy =>
{
policy.AuthenticationSchemes.Add(StellaOpsAuthenticationDefaults.AuthenticationScheme);
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes, requireAllScopes: false));
});
}
/// <summary>
/// Adds the scope handler to the DI container.
/// </summary>

View File

@@ -65,31 +65,10 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
? ExtractScopes(principal!)
: new HashSet<string>(StringComparer.Ordinal);
var anyScopeMatched = false;
var anyScopeMatched = combinedScopes.Any(principalScopes.Contains);
var missingScopes = new List<string>();
if (principalAuthenticated)
{
foreach (var scope in combinedScopes)
{
if (principalScopes.Contains(scope))
{
anyScopeMatched = true;
}
else
{
missingScopes.Add(scope);
}
}
}
else if (combinedScopes.Count > 0)
{
missingScopes.AddRange(combinedScopes);
}
var allScopesSatisfied = combinedScopes.Count == 0
? false
: missingScopes.Count == 0;
var allScopesSatisfied = principalAuthenticated &&
EvaluateScopeSatisfaction(resourceOptions.NormalizedScopes, requirement, principalScopes, missingScopes);
var tenantAllowed = false;
var tenantMismatch = false;
@@ -333,6 +312,42 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
packPlanHashClaim).ConfigureAwait(false);
}
private static bool EvaluateScopeSatisfaction(
IReadOnlyCollection<string> defaultScopes,
StellaOpsScopeRequirement requirement,
IReadOnlyCollection<string> principalScopes,
ICollection<string> missingScopes)
{
var resolvedDefaultScopes = defaultScopes as IReadOnlyList<string> ?? defaultScopes.ToArray();
var combinedScopes = CombineRequiredScopes(resolvedDefaultScopes, requirement.RequiredScopes);
if (requirement.RequireAllScopes)
{
foreach (var scope in combinedScopes)
{
if (!principalScopes.Contains(scope))
{
missingScopes.Add(scope);
}
}
return missingScopes.Count == 0;
}
var anyRequirementMatched = combinedScopes.Any(principalScopes.Contains);
if (anyRequirementMatched)
{
return true;
}
foreach (var scope in combinedScopes)
{
missingScopes.Add(scope);
}
return false;
}
private static string? DetermineFailureReason(
bool principalAuthenticated,
bool allScopesSatisfied,

View File

@@ -16,7 +16,11 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
/// Initialises a new instance of the <see cref="StellaOpsScopeRequirement"/> class.
/// </summary>
/// <param name="scopes">Scopes that satisfy the requirement.</param>
public StellaOpsScopeRequirement(IEnumerable<string> scopes)
/// <param name="requireAllScopes">
/// When <see langword="true"/>, every required scope must be present.
/// When <see langword="false"/>, any one required scope satisfies the requirement.
/// </param>
public StellaOpsScopeRequirement(IEnumerable<string> scopes, bool requireAllScopes = true)
{
ArgumentNullException.ThrowIfNull(scopes);
@@ -39,10 +43,16 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
}
RequiredScopes = normalized.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
RequireAllScopes = requireAllScopes;
}
/// <summary>
/// Gets the required scopes.
/// </summary>
public IReadOnlyCollection<string> RequiredScopes { get; }
/// <summary>
/// Gets a value indicating whether all listed scopes are required.
/// </summary>
public bool RequireAllScopes { get; }
}

View File

@@ -0,0 +1,476 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
namespace StellaOps.Platform.WebService.Endpoints;
public static class AocCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapAocCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/aoc")
.WithTags("AOC Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AocVerify))
.RequireTenant();
group.MapGet("/metrics", (
HttpContext httpContext,
[FromQuery] string? tenantId,
[FromQuery] int? windowMinutes,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var effectiveWindowMinutes = windowMinutes is > 0 ? windowMinutes.Value : 1440;
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
passCount = 12847,
failCount = 23,
totalCount = 12870,
passRate = 0.9982,
recentViolations = BuildViolationSummaries(now),
ingestThroughput = new
{
docsPerMinute = 8.9,
avgLatencyMs = 145,
p95LatencyMs = 312,
queueDepth = 3,
errorRate = 0.18
},
timeWindow = new
{
start = now.AddMinutes(-effectiveWindowMinutes).ToString("O", CultureInfo.InvariantCulture),
end = now.ToString("O", CultureInfo.InvariantCulture),
durationMinutes = effectiveWindowMinutes
}
});
})
.WithName("AocCompatibility.GetMetrics");
group.MapPost("/verify", (
HttpContext httpContext,
AocVerifyRequest request,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, request.TenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
verificationId = $"verify-{tenant}-{now:yyyyMMddHHmmss}",
status = "partial",
checkedCount = Math.Clamp(request.Limit ?? 250, 1, 1000),
passedCount = 247,
failedCount = 3,
violations = new[]
{
new
{
documentId = "sbom-nginx-prod",
violationCode = "AOC-PROV-001",
field = "provenance.digest",
expected = "signed",
actual = "missing",
provenance = new
{
sourceId = "docker-hub",
ingestedAt = now.AddMinutes(-20).ToString("O", CultureInfo.InvariantCulture),
digest = "sha256:4fdb5e6a31a80f0d",
sourceType = "registry",
sourceUrl = "docker.io/library/nginx:1.27.4",
submitter = "scanner-agent-01"
}
}
},
completedAt = now.ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("AocCompatibility.Verify");
group.MapGet("/compliance/dashboard", (
HttpContext httpContext,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
metrics = new
{
guardViolations = new
{
count = 23,
percentage = 0.18,
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
{
["schema_invalid"] = 9,
["missing_required_fields"] = 8,
["hash_mismatch"] = 6
},
trend = "stable"
},
provenanceCompleteness = new
{
percentage = 99.1,
recordsWithValidHash = 12755,
totalRecords = 12870,
trend = "up"
},
deduplicationRate = new
{
percentage = 12.4,
duplicatesDetected = 1595,
totalIngested = 12870,
trend = "stable"
},
ingestionLatency = new
{
p50Ms = 122,
p95Ms = 301,
p99Ms = 410,
meetsSla = true,
slaTargetP95Ms = 500
},
supersedesDepth = new
{
maxDepth = 4,
avgDepth = 1.6,
distribution = new[]
{
new { depth = 1, count = 1180 },
new { depth = 2, count = 242 },
new { depth = 3, count = 44 },
new { depth = 4, count = 6 }
}
},
periodStart = now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
periodEnd = now.ToString("O", CultureInfo.InvariantCulture)
},
recentViolations = BuildGuardViolations(now, page: 1, pageSize: 5).Items,
ingestionFlow = BuildIngestionFlow(now)
});
})
.WithName("AocCompatibility.GetComplianceDashboard");
group.MapGet("/compliance/violations", (
[FromQuery] int? page,
[FromQuery] int? pageSize,
TimeProvider timeProvider) =>
{
var effectivePage = page is > 0 ? page.Value : 1;
var effectivePageSize = pageSize is > 0 ? pageSize.Value : 20;
var response = BuildGuardViolations(timeProvider.GetUtcNow(), effectivePage, effectivePageSize);
return Results.Ok(response);
})
.WithName("AocCompatibility.GetViolations");
group.MapPost("/compliance/violations/{violationId}/retry", (string violationId) =>
Results.Ok(new
{
success = true,
message = $"Retry scheduled for {violationId}."
}))
.WithName("AocCompatibility.RetryViolation");
group.MapGet("/ingestion/flow", ([FromServices] TimeProvider timeProvider) =>
Results.Ok(BuildIngestionFlow(timeProvider.GetUtcNow())))
.WithName("AocCompatibility.GetIngestionFlow");
group.MapPost("/provenance/validate", (
AocProvenanceValidateRequest request,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var inputType = string.IsNullOrWhiteSpace(request.InputType) ? "finding_id" : request.InputType!;
var inputValue = string.IsNullOrWhiteSpace(request.InputValue) ? "finding-001" : request.InputValue!;
return Results.Ok(new
{
inputType,
inputValue,
steps = new[]
{
new AocProvenanceStep(
"source",
"Registry intake",
now.AddMinutes(-40).ToString("O", CultureInfo.InvariantCulture),
"sha256:1f7d98a2bf54c390",
null,
"valid",
new Dictionary<string, object?>(StringComparer.Ordinal)
{
["source"] = "docker-hub",
["artifact"] = "nginx:1.27.4"
}),
new AocProvenanceStep(
"normalized",
"Concelier normalization",
now.AddMinutes(-33).ToString("O", CultureInfo.InvariantCulture),
"sha256:4fdb5e6a31a80f0d",
"sha256:1f7d98a2bf54c390",
"valid",
new Dictionary<string, object?>(StringComparer.Ordinal)
{
["pipeline"] = "concelier",
["tenant"] = "demo-prod"
}),
new AocProvenanceStep(
"finding",
"Finding materialized",
now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
"sha256:0a52b69d9f9c72c0",
"sha256:4fdb5e6a31a80f0d",
"valid",
new Dictionary<string, object?>(StringComparer.Ordinal)
{
["findingId"] = inputValue,
["decision"] = "warn"
})
},
isComplete = true,
validationErrors = Array.Empty<string>(),
validatedAt = now.ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("AocCompatibility.ValidateProvenance");
group.MapPost("/compliance/reports", (
AocComplianceReportRequest request,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
reportId = $"aoc-report-{now:yyyyMMddHHmmss}",
generatedAt = now.ToString("O", CultureInfo.InvariantCulture),
period = new
{
start = request.StartDate ?? now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
end = request.EndDate ?? now.ToString("O", CultureInfo.InvariantCulture)
},
guardViolationSummary = new
{
total = 23,
bySource = new Dictionary<string, int>(StringComparer.Ordinal)
{
["docker-hub"] = 12,
["github-packages"] = 11
},
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
{
["schema_invalid"] = 9,
["missing_required_fields"] = 8,
["hash_mismatch"] = 6
}
},
provenanceCompliance = new
{
percentage = 99.1,
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
{
["docker-hub"] = 99.5,
["github-packages"] = 98.7
}
},
deduplicationMetrics = new
{
rate = 12.4,
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
{
["docker-hub"] = 10.8,
["github-packages"] = 14.1
}
},
latencyMetrics = new
{
p50Ms = 122,
p95Ms = 301,
p99Ms = 410,
bySource = new Dictionary<string, object>(StringComparer.Ordinal)
{
["docker-hub"] = new { p50 = 118, p95 = 292, p99 = 401 },
["github-packages"] = new { p50 = 126, p95 = 312, p99 = 420 }
}
}
});
})
.WithName("AocCompatibility.GenerateComplianceReport");
return app;
}
private static object[] BuildViolationSummaries(DateTimeOffset now) =>
[
new
{
code = "AOC-PROV-001",
description = "Missing provenance attestation",
count = 12,
severity = "high",
lastSeen = now.AddMinutes(-15).ToString("O", CultureInfo.InvariantCulture)
},
new
{
code = "AOC-DIGEST-002",
description = "Digest mismatch in manifest",
count = 7,
severity = "critical",
lastSeen = now.AddMinutes(-42).ToString("O", CultureInfo.InvariantCulture)
},
new
{
code = "AOC-SCHEMA-003",
description = "Schema validation failed",
count = 4,
severity = "medium",
lastSeen = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
}
];
private static object BuildIngestionFlow(DateTimeOffset now) => new
{
sources = new[]
{
new
{
sourceId = "docker-hub",
sourceName = "Docker Hub",
module = "concelier",
throughputPerMinute = 5.2,
latencyP50Ms = 112,
latencyP95Ms = 284,
latencyP99Ms = 365,
errorRate = 0.12,
backlogDepth = 2,
lastIngestionAt = now.AddMinutes(-3).ToString("O", CultureInfo.InvariantCulture),
status = "healthy"
},
new
{
sourceId = "github-packages",
sourceName = "GitHub Packages",
module = "excititor",
throughputPerMinute = 3.7,
latencyP50Ms = 133,
latencyP95Ms = 318,
latencyP99Ms = 411,
errorRate = 0.24,
backlogDepth = 1,
lastIngestionAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
status = "degraded"
}
},
totalThroughput = 8.9,
avgLatencyP95Ms = 301,
overallErrorRate = 0.18,
lastUpdatedAt = now.ToString("O", CultureInfo.InvariantCulture)
};
private static AocGuardViolationResponse BuildGuardViolations(DateTimeOffset now, int page, int pageSize)
{
var all = new[]
{
new AocGuardViolation(
"viol-001",
now.AddMinutes(-16).ToString("O", CultureInfo.InvariantCulture),
"docker-hub",
"missing_required_fields",
"Provenance digest missing from normalized advisory.",
"{\"digest\":null}",
"concelier",
true),
new AocGuardViolation(
"viol-002",
now.AddMinutes(-44).ToString("O", CultureInfo.InvariantCulture),
"github-packages",
"hash_mismatch",
"Manifest digest did not match DSSE payload.",
"{\"expected\":\"sha256:a\",\"actual\":\"sha256:b\"}",
"excititor",
true),
new AocGuardViolation(
"viol-003",
now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
"docker-hub",
"schema_invalid",
"Document failed schema validation for SPDX 2.3.",
"{\"schema\":\"spdx-2.3\"}",
"concelier",
false)
};
var effectivePage = page > 0 ? page : 1;
var effectivePageSize = pageSize > 0 ? pageSize : 20;
var skip = (effectivePage - 1) * effectivePageSize;
var items = all.Skip(skip).Take(effectivePageSize).ToArray();
return new AocGuardViolationResponse(
items,
all.Length,
effectivePage,
effectivePageSize,
skip + items.Length < all.Length);
}
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
=> tenantId?.Trim()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.User.Claims.FirstOrDefault(static claim =>
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
private sealed record AocVerifyRequest(string? TenantId, string? Since, int? Limit);
private sealed record AocProvenanceValidateRequest(string? InputType, string? InputValue);
private sealed record AocComplianceReportRequest(
string? StartDate,
string? EndDate,
IReadOnlyList<string>? Sources,
string? Format,
bool IncludeViolationDetails);
private sealed record AocGuardViolation(
string Id,
string Timestamp,
string Source,
string Reason,
string Message,
string PayloadSample,
string Module,
bool CanRetry);
private sealed record AocGuardViolationResponse(
IReadOnlyList<AocGuardViolation> Items,
int TotalCount,
int Page,
int PageSize,
bool HasMore);
private sealed record AocProvenanceStep(
string StepType,
string Label,
string Timestamp,
string Hash,
string? LinkedFromHash,
string Status,
IReadOnlyDictionary<string, object?> Details);
}

View File

@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Platform.WebService.Endpoints;
public static class ConsoleCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapConsoleCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/console/status", (
HttpContext httpContext,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var now = timeProvider.GetUtcNow();
var backlog = Math.Abs(tenant.GetHashCode(StringComparison.Ordinal)) % 8 + 4;
var activeRuns = (backlog % 3) + 1;
return Results.Ok(new
{
backlog,
queueLagMs = 180 + (backlog * 35),
activeRuns,
pendingRuns = Math.Max(0, backlog - activeRuns),
lastCompletedRunId = $"run::{tenant}::{now:yyyyMMdd}",
lastCompletedAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
healthy = true
});
})
.WithTags("Console Compatibility")
.WithName("ConsoleStatus.Get")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
app.MapGet("/api/console/runs/{runId}/first-signal", (
HttpContext httpContext,
[FromRoute] string runId,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var signal = CreateCompatibilityFirstSignal(runId, tenant, timeProvider);
var typedHeaders = httpContext.Request.GetTypedHeaders();
if (typedHeaders.IfNoneMatch?.Any(tag => string.Equals(tag.Tag.Value, signal.SummaryEtag, StringComparison.Ordinal)) == true)
{
httpContext.Response.Headers.ETag = signal.SummaryEtag;
httpContext.Response.Headers.Append("Cache-Status", "compatibility; hit");
return Results.StatusCode(StatusCodes.Status304NotModified);
}
httpContext.Response.Headers.ETag = signal.SummaryEtag;
httpContext.Response.Headers.Append("Cache-Status", "compatibility; generated");
return Results.Ok(signal);
})
.WithTags("Console Compatibility")
.WithName("ConsoleStatus.FirstSignal")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
return app;
}
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
=> tenantId?.Trim()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.User.Claims.FirstOrDefault(static claim =>
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
private static ConsoleCompatibilityFirstSignalResponse CreateCompatibilityFirstSignal(
string runId,
string tenant,
TimeProvider timeProvider)
{
var completedAt = ResolveCompletedAt(runId, timeProvider.GetUtcNow());
var summaryEtag = CreateSummaryEtag(tenant, runId, completedAt);
return new ConsoleCompatibilityFirstSignalResponse(
runId,
new ConsoleCompatibilityFirstSignal(
Type: "completed",
Stage: "console",
Step: "snapshot",
Message: $"Console captured the latest completed snapshot for {runId}.",
At: completedAt.ToString("O", CultureInfo.InvariantCulture),
Artifact: new ConsoleCompatibilityFirstSignalArtifact("run")),
summaryEtag);
}
private static DateTimeOffset ResolveCompletedAt(string runId, DateTimeOffset now)
{
var segments = runId.Split("::", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (segments.Length > 0 &&
DateOnly.TryParseExact(segments[^1], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var completedOn))
{
return completedOn.ToDateTime(TimeOnly.FromTimeSpan(TimeSpan.FromHours(12)), DateTimeKind.Utc);
}
return now.AddMinutes(-6);
}
private static string CreateSummaryEtag(string tenant, string runId, DateTimeOffset completedAt)
{
var material = Encoding.UTF8.GetBytes($"{tenant}|{runId}|{completedAt:O}");
var digest = Convert.ToHexStringLower(SHA256.HashData(material));
return $"\"compat-first-signal-{digest[..16]}\"";
}
private sealed record ConsoleCompatibilityFirstSignalResponse(
string RunId,
ConsoleCompatibilityFirstSignal FirstSignal,
string SummaryEtag);
private sealed record ConsoleCompatibilityFirstSignal(
string Type,
string Stage,
string Step,
string Message,
string At,
ConsoleCompatibilityFirstSignalArtifact Artifact);
private sealed record ConsoleCompatibilityFirstSignalArtifact(string Kind);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Auth.ServerIntegration.Tenancy;
@@ -120,8 +121,8 @@ builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthRead, PlatformScopes.OpsHealth);
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthAdmin, PlatformScopes.OpsAdmin);
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin);
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead, StellaOpsScopes.OrchQuota);
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin, StellaOpsScopes.OrchQuota);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingRead, PlatformScopes.OnboardingRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
@@ -325,6 +326,8 @@ app.MapEvidenceReadModelEndpoints();
app.MapIntegrationReadModelEndpoints();
app.MapLegacyAliasEndpoints();
app.MapPackAdapterEndpoints();
app.MapConsoleCompatibilityEndpoints();
app.MapAocCompatibilityEndpoints();
app.MapAdministrationTrustSigningMutationEndpoints();
app.MapFederationTelemetryEndpoints();
app.MapSeedEndpoints();

View File

@@ -0,0 +1,113 @@
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class CompatibilityEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public CompatibilityEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ConsoleStatus_ReturnsDeterministicPayload()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/console/status", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(payload.TryGetProperty("healthy", out var healthy));
Assert.True(healthy.GetBoolean());
Assert.True(payload.GetProperty("backlog").GetInt32() > 0);
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("lastCompletedRunId").GetString()));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ConsoleRunFirstSignal_ReturnsCompatibilitySnapshot_AndHonorsConditionalRequests()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/console/runs/run::demo-prod::20260309/first-signal", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("run::demo-prod::20260309", payload.GetProperty("runId").GetString());
Assert.Equal("completed", payload.GetProperty("firstSignal").GetProperty("type").GetString());
Assert.Equal("snapshot", payload.GetProperty("firstSignal").GetProperty("step").GetString());
Assert.Equal("run", payload.GetProperty("firstSignal").GetProperty("artifact").GetProperty("kind").GetString());
var etag = response.Headers.ETag?.Tag;
Assert.False(string.IsNullOrWhiteSpace(etag));
Assert.Contains("compatibility", string.Join(", ", response.Headers.GetValues("Cache-Status")));
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/api/console/runs/run::demo-prod::20260309/first-signal");
conditionalRequest.Headers.Add("X-StellaOps-Tenant", "demo-prod");
conditionalRequest.Headers.TryAddWithoutValidation("If-None-Match", etag);
var notModified = await client.SendAsync(conditionalRequest, TestContext.Current.CancellationToken);
Assert.Equal(System.Net.HttpStatusCode.NotModified, notModified.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AocMetrics_RespectRequestedWindow()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/v1/aoc/metrics?windowMinutes=60", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal(12870, payload.GetProperty("totalCount").GetInt32());
Assert.Equal(60, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
Assert.True(payload.GetProperty("recentViolations").GetArrayLength() > 0);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AocMetrics_DefaultWindowWhenCallerOmitsWindowMinutes()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/v1/aoc/metrics", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal(1440, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AocComplianceEndpoints_ReturnDashboardAndRetryableViolations()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var dashboardResponse = await client.GetAsync("/api/v1/aoc/compliance/dashboard", TestContext.Current.CancellationToken);
dashboardResponse.EnsureSuccessStatusCode();
var dashboard = await dashboardResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(dashboard.TryGetProperty("metrics", out _));
Assert.True(dashboard.GetProperty("recentViolations").GetArrayLength() > 0);
var retryResponse = await client.PostAsJsonAsync(
"/api/v1/aoc/compliance/violations/viol-001/retry",
new { },
TestContext.Current.CancellationToken);
retryResponse.EnsureSuccessStatusCode();
var retryPayload = await retryResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(retryPayload.GetProperty("success").GetBoolean());
}
}

View File

@@ -2,6 +2,10 @@ using System;
using System.Linq;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
@@ -124,4 +128,29 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
Assert.NotNull(body);
Assert.Equal("tenant_forbidden", body!["error"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QuotaPolicies_AcceptLegacyOrchQuotaScope()
{
var policyProvider = factory.Services.GetRequiredService<IAuthorizationPolicyProvider>();
var readPolicy = await policyProvider.GetPolicyAsync("platform.quota.read");
var adminPolicy = await policyProvider.GetPolicyAsync("platform.quota.admin");
Assert.NotNull(readPolicy);
Assert.NotNull(adminPolicy);
var readScopes = Assert.Single(readPolicy!.Requirements.OfType<StellaOpsScopeRequirement>()).RequiredScopes;
var readRequirement = Assert.Single(readPolicy!.Requirements.OfType<StellaOpsScopeRequirement>());
var adminRequirement = Assert.Single(adminPolicy!.Requirements.OfType<StellaOpsScopeRequirement>());
var adminScopes = adminRequirement.RequiredScopes;
Assert.Contains(StellaOpsScopes.OrchQuota, readScopes);
Assert.Contains("quota.read", readScopes);
Assert.Contains(StellaOpsScopes.OrchQuota, adminScopes);
Assert.Contains("quota.admin", adminScopes);
Assert.False(readRequirement.RequireAllScopes);
Assert.False(adminRequirement.RequireAllScopes);
}
}

View File

@@ -0,0 +1,388 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.Policy.Gateway.Endpoints;
public static class GovernanceCompatibilityEndpoints
{
private static readonly ConcurrentDictionary<string, TrustWeightConfigState> TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, StalenessConfigState> StalenessStates = new(StringComparer.OrdinalIgnoreCase);
public static void MapGovernanceCompatibilityEndpoints(this WebApplication app)
{
var governance = app.MapGroup("/api/v1/governance")
.WithTags("Governance Compatibility")
.RequireTenant();
governance.MapGet("/trust-weights", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.ToResponse());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapPut("/trust-weights/{weightId}", (
HttpContext context,
string weightId,
[FromBody] TrustWeightWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
var updated = state.Upsert(weightId, request, timeProvider, StellaOpsTenantResolver.ResolveActor(context));
return Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapDelete("/trust-weights/{weightId}", (
HttpContext context,
string weightId,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
state.Delete(weightId, timeProvider);
return Results.NoContent();
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapPost("/trust-weights/preview-impact", (
[FromBody] TrustWeightPreviewRequest request) =>
{
var weights = request.Weights ?? [];
var severityChanges = weights.Count(static weight => weight.Weight >= 1.2m);
var decisionChanges = weights.Count(static weight => weight.Active != false);
var payload = new
{
affectedVulnerabilities = Math.Max(3, weights.Count * 9),
severityChanges,
decisionChanges,
sampleAffected = weights.Take(3).Select((weight, index) => new
{
findingId = $"finding-{index + 1:000}",
componentPurl = $"pkg:oci/{weight.IssuerId ?? "stellaops"}/runtime-{index + 1}@sha256:{(index + 1).ToString("D4")}",
advisoryId = $"CVE-2026-{1400 + index}",
currentSeverity = index == 0 ? "high" : "medium",
projectedSeverity = weight.Weight >= 1.2m ? "critical" : "high",
currentDecision = "warn",
projectedDecision = weight.Active != false ? "deny" : "warn"
}).ToArray(),
severityTransitions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["medium->high"] = Math.Max(1, severityChanges),
["high->critical"] = Math.Max(1, severityChanges / 2)
}
};
return Results.Ok(payload);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapGet("/staleness/config", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.ToResponse());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapPut("/staleness/config/{dataType}", (
HttpContext context,
string dataType,
[FromBody] StalenessConfigWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
var updated = state.Upsert(dataType, request, timeProvider);
return Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapGet("/staleness/status", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.BuildStatus());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
}
private static GovernanceScope ResolveScope(HttpContext context, string? projectId)
{
if (!StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error))
{
throw new InvalidOperationException($"Tenant resolution failed: {error ?? "tenant_missing"}");
}
var scopedProject = string.IsNullOrWhiteSpace(projectId)
? StellaOpsTenantResolver.ResolveProject(context)
: projectId.Trim();
return new GovernanceScope(
tenantId,
string.IsNullOrWhiteSpace(scopedProject) ? null : scopedProject,
string.IsNullOrWhiteSpace(scopedProject) ? tenantId : $"{tenantId}:{scopedProject}");
}
private static TrustWeightConfigState CreateDefaultTrustWeightState(string tenantId, string? projectId, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow().ToString("O");
return new TrustWeightConfigState(
tenantId,
projectId,
now,
"\"trust-weights-v1\"",
[
new TrustWeightRecord("tw-001", "cisa", "CISA", "cisa", 1.50m, 1, true, "Government authoritative source", now, "system"),
new TrustWeightRecord("tw-002", "nist", "NIST NVD", "nist", 1.30m, 2, true, "Primary CVE source", now, "system"),
new TrustWeightRecord("tw-003", "vendor-redhat", "Red Hat", "vendor", 1.20m, 3, true, "Trusted vendor feed", now, "system")
]);
}
private static StalenessConfigState CreateDefaultStalenessState(string tenantId, string? projectId, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow().ToString("O");
return new StalenessConfigState(
tenantId,
projectId,
now,
"\"staleness-v1\"",
[
new StalenessConfigRecord("sbom", BuildThresholds(7, 14, 30, 45), true, 12),
new StalenessConfigRecord("vulnerability_data", BuildThresholds(1, 3, 7, 14), true, 6),
new StalenessConfigRecord("vex_statements", BuildThresholds(3, 7, 14, 21), true, 12),
new StalenessConfigRecord("policy", BuildThresholds(14, 30, 45, 60), false, 24),
new StalenessConfigRecord("attestation", BuildThresholds(7, 14, 21, 30), true, 8),
new StalenessConfigRecord("scan_result", BuildThresholds(1, 2, 5, 10), true, 4)
]);
}
private static List<StalenessThresholdRecord> BuildThresholds(int fresh, int aging, int stale, int expired) =>
[
new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
new("aging", aging, "medium", [new StalenessActionRecord("notify", "Approaching review window.")]),
new("stale", stale, "high", [new StalenessActionRecord("flag_review", "Operator review required.")]),
new("expired", expired, "critical", [new StalenessActionRecord("block", "Fresh data required before continue.")])
];
private sealed record GovernanceScope(string TenantId, string? ProjectId, string Key);
private sealed class TrustWeightConfigState(
string tenantId,
string? projectId,
string modifiedAt,
string etag,
List<TrustWeightRecord> weights)
{
public string TenantId { get; private set; } = tenantId;
public string? ProjectId { get; private set; } = projectId;
public string ModifiedAt { get; private set; } = modifiedAt;
public string Etag { get; private set; } = etag;
public List<TrustWeightRecord> Weights { get; } = weights;
public object ToResponse() => new
{
tenantId = TenantId,
projectId = ProjectId,
weights = Weights.OrderBy(static weight => weight.Priority).ThenBy(static weight => weight.IssuerName, StringComparer.OrdinalIgnoreCase),
defaultWeight = 1.0m,
modifiedAt = ModifiedAt,
etag = Etag
};
public object Upsert(string routeWeightId, TrustWeightWriteModel request, TimeProvider timeProvider, string actor)
{
var effectiveId = string.IsNullOrWhiteSpace(request.Id) ? routeWeightId : request.Id.Trim();
var index = Weights.FindIndex(weight => string.Equals(weight.Id, effectiveId, StringComparison.OrdinalIgnoreCase));
var existing = index >= 0 ? Weights[index] : null;
var now = timeProvider.GetUtcNow().ToString("O");
var updated = new TrustWeightRecord(
effectiveId,
request.IssuerId?.Trim() ?? existing?.IssuerId ?? effectiveId,
request.IssuerName?.Trim() ?? existing?.IssuerName ?? effectiveId,
NormalizeSource(request.Source ?? existing?.Source),
request.Weight ?? existing?.Weight ?? 1.0m,
request.Priority ?? existing?.Priority ?? Weights.Count + 1,
request.Active ?? existing?.Active ?? true,
request.Reason?.Trim() ?? existing?.Reason,
now,
actor);
if (index >= 0)
{
Weights[index] = updated;
}
else
{
Weights.Add(updated);
}
ModifiedAt = now;
Etag = $"\"trust-weights-{Weights.Count}-{now}\"";
return updated;
}
public void Delete(string weightId, TimeProvider timeProvider)
{
Weights.RemoveAll(weight => string.Equals(weight.Id, weightId, StringComparison.OrdinalIgnoreCase));
ModifiedAt = timeProvider.GetUtcNow().ToString("O");
Etag = $"\"trust-weights-{Weights.Count}-{ModifiedAt}\"";
}
}
private sealed class StalenessConfigState(
string tenantId,
string? projectId,
string modifiedAt,
string etag,
List<StalenessConfigRecord> configs)
{
public string TenantId { get; private set; } = tenantId;
public string? ProjectId { get; private set; } = projectId;
public string ModifiedAt { get; private set; } = modifiedAt;
public string Etag { get; private set; } = etag;
public List<StalenessConfigRecord> Configs { get; } = configs;
public object ToResponse() => new
{
tenantId = TenantId,
projectId = ProjectId,
configs = Configs.OrderBy(static config => config.DataType, StringComparer.OrdinalIgnoreCase),
modifiedAt = ModifiedAt,
etag = Etag
};
public object Upsert(string dataType, StalenessConfigWriteModel request, TimeProvider timeProvider)
{
var effectiveType = string.IsNullOrWhiteSpace(dataType) ? request.DataType?.Trim() ?? "sbom" : dataType.Trim();
var thresholds = request.Thresholds?.Count > 0
? request.Thresholds.Select(threshold => new StalenessThresholdRecord(
NormalizeLevel(threshold.Level),
threshold.AgeDays,
NormalizeSeverity(threshold.Severity),
threshold.Actions?.Select(action => new StalenessActionRecord(NormalizeActionType(action.Type), action.Message, action.Channels)).ToList() ?? [])).ToList()
: BuildThresholds(7, 14, 30, 45);
var updated = new StalenessConfigRecord(
effectiveType,
thresholds,
request.Enabled ?? true,
request.GracePeriodHours ?? 12);
var index = Configs.FindIndex(config => string.Equals(config.DataType, effectiveType, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
{
Configs[index] = updated;
}
else
{
Configs.Add(updated);
}
ModifiedAt = timeProvider.GetUtcNow().ToString("O");
Etag = $"\"staleness-{Configs.Count}-{ModifiedAt}\"";
return updated;
}
public object[] BuildStatus() =>
Configs.Select((config, index) => new
{
dataType = config.DataType,
itemId = $"{config.DataType}-asset-{index + 1}",
itemName = $"{config.DataType.Replace('_', ' ')} snapshot {index + 1}",
lastUpdatedAt = DateTimeOffset.Parse(ModifiedAt).AddDays(-(index + 1) * 3).ToString("O"),
ageDays = (index + 1) * 3,
level = index == 0 ? "fresh" : index == 1 ? "aging" : index == 2 ? "stale" : "expired",
blocked = index >= 2 && config.Enabled
}).ToArray();
}
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
private static string NormalizeSeverity(string? severity) =>
string.IsNullOrWhiteSpace(severity) ? "medium" : severity.Trim().ToLowerInvariant();
private static string NormalizeLevel(string? level) =>
string.IsNullOrWhiteSpace(level) ? "fresh" : level.Trim().ToLowerInvariant();
private static string NormalizeActionType(string? actionType) =>
string.IsNullOrWhiteSpace(actionType) ? "warn" : actionType.Trim().ToLowerInvariant();
private sealed record TrustWeightRecord(
string Id,
string IssuerId,
string IssuerName,
string Source,
decimal Weight,
int Priority,
bool Active,
string? Reason,
string ModifiedAt,
string ModifiedBy);
private sealed record StalenessConfigRecord(
string DataType,
List<StalenessThresholdRecord> Thresholds,
bool Enabled,
int GracePeriodHours);
private sealed record StalenessThresholdRecord(
string Level,
int AgeDays,
string Severity,
List<StalenessActionRecord> Actions);
private sealed record StalenessActionRecord(
string Type,
string? Message = null,
string[]? Channels = null);
}
public sealed record TrustWeightWriteModel
{
public string? Id { get; init; }
public string? IssuerId { get; init; }
public string? IssuerName { get; init; }
public string? Source { get; init; }
public decimal? Weight { get; init; }
public int? Priority { get; init; }
public bool? Active { get; init; }
public string? Reason { get; init; }
}
public sealed record TrustWeightPreviewRequest(IReadOnlyList<TrustWeightWriteModel>? Weights);
public sealed record StalenessConfigWriteModel
{
public string? DataType { get; init; }
public IReadOnlyList<StalenessThresholdWriteModel>? Thresholds { get; init; }
public bool? Enabled { get; init; }
public int? GracePeriodHours { get; init; }
}
public sealed record StalenessThresholdWriteModel
{
public string? Level { get; init; }
public int AgeDays { get; init; }
public string? Severity { get; init; }
public IReadOnlyList<StalenessActionWriteModel>? Actions { get; init; }
}
public sealed record StalenessActionWriteModel
{
public string? Type { get; init; }
public string? Message { get; init; }
public string[]? Channels { get; init; }
}

View File

@@ -676,6 +676,9 @@ app.MapExceptionEndpoints();
// Delta management endpoints
app.MapDeltasEndpoints();
// Policy simulation compatibility endpoints for live console routes.
app.MapPolicySimulationEndpoints();
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
app.MapGateEndpoints();
@@ -693,6 +696,7 @@ app.MapExceptionApprovalEndpoints();
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
app.MapGovernanceEndpoints();
app.MapGovernanceCompatibilityEndpoints();
// Advisory source impact/conflict endpoints (Sprint: SPRINT_20260219_008, Task: BE8-05)
app.MapAdvisorySourcePolicyEndpoints();

View File

@@ -0,0 +1,112 @@
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class GovernanceCompatibilityEndpointsTests : IClassFixture<TestPolicyGatewayFactory>
{
private readonly HttpClient _client;
public GovernanceCompatibilityEndpointsTests(TestPolicyGatewayFactory factory)
{
_client = factory.CreateClient();
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task GetTrustWeights_ReturnsCompatibilityShape()
{
var response = await _client.GetAsync("/api/v1/governance/trust-weights", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("test-tenant", payload.GetProperty("tenantId").GetString());
Assert.True(payload.GetProperty("weights").GetArrayLength() >= 3);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task PutTrustWeight_PersistsUpdatedPriorityAndWeight()
{
var updateResponse = await _client.PutAsJsonAsync(
"/api/v1/governance/trust-weights/tw-002",
new
{
id = "tw-002",
issuerId = "nist",
issuerName = "NIST NVD",
source = "nist",
weight = 1.75m,
priority = 1,
active = true,
reason = "Escalated for live route verification"
},
TestContext.Current.CancellationToken);
updateResponse.EnsureSuccessStatusCode();
var listResponse = await _client.GetAsync("/api/v1/governance/trust-weights", TestContext.Current.CancellationToken);
listResponse.EnsureSuccessStatusCode();
var payload = await listResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
var updated = payload.GetProperty("weights").EnumerateArray().Single(item => item.GetProperty("id").GetString() == "tw-002");
Assert.Equal(1.75m, updated.GetProperty("weight").GetDecimal());
Assert.Equal(1, updated.GetProperty("priority").GetInt32());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task PreviewImpact_ReturnsAffectedFindingSamples()
{
var response = await _client.PostAsJsonAsync(
"/api/v1/governance/trust-weights/preview-impact",
new
{
weights = new[]
{
new { issuerId = "cisa", issuerName = "CISA", source = "cisa", weight = 1.6m, active = true }
}
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(payload.GetProperty("affectedVulnerabilities").GetInt32() > 0);
Assert.True(payload.GetProperty("sampleAffected").GetArrayLength() > 0);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task StalenessEndpoints_ReturnConfigAndStatusUpdates()
{
var updateResponse = await _client.PutAsJsonAsync(
"/api/v1/governance/staleness/config/sbom",
new
{
dataType = "sbom",
enabled = false,
gracePeriodHours = 48,
thresholds = new[]
{
new { level = "fresh", ageDays = 14, severity = "low", actions = new[] { new { type = "warn", message = "Fresh enough" } } }
}
},
TestContext.Current.CancellationToken);
updateResponse.EnsureSuccessStatusCode();
var configResponse = await _client.GetAsync("/api/v1/governance/staleness/config", TestContext.Current.CancellationToken);
configResponse.EnsureSuccessStatusCode();
var config = await configResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
var sbomConfig = config.GetProperty("configs").EnumerateArray().Single(item => item.GetProperty("dataType").GetString() == "sbom");
Assert.False(sbomConfig.GetProperty("enabled").GetBoolean());
Assert.Equal(48, sbomConfig.GetProperty("gracePeriodHours").GetInt32());
var statusResponse = await _client.GetAsync("/api/v1/governance/staleness/status", TestContext.Current.CancellationToken);
statusResponse.EnsureSuccessStatusCode();
var status = await statusResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(status.ValueKind == JsonValueKind.Array);
Assert.True(status.GetArrayLength() > 0);
}
}

View 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);
}

View File

@@ -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)

View File

@@ -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);
}
}