diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs index aedfe776c..aecb4ff37 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration.Tests/StellaOpsScopeAuthorizationHandlerTests.cs @@ -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() diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs index ee0b69ec1..e6c9e9d86 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsAuthorizationPolicyBuilderExtensions.cs @@ -33,6 +33,25 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions return builder; } + /// + /// Requires that any one of the specified StellaOps scopes be present. + /// + 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; + } + /// /// Registers a named policy that enforces the provided scopes. /// @@ -51,6 +70,24 @@ public static class StellaOpsAuthorizationPolicyBuilderExtensions }); } + /// + /// Registers a named policy that is satisfied when any listed scope is granted. + /// + 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)); + }); + } + /// /// Adds the scope handler to the DI container. /// diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs index 1e3bb9c12..91636ad1e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeAuthorizationHandler.cs @@ -65,31 +65,10 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler< ? ExtractScopes(principal!) : new HashSet(StringComparer.Ordinal); - var anyScopeMatched = false; + var anyScopeMatched = combinedScopes.Any(principalScopes.Contains); var missingScopes = new List(); - - 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 defaultScopes, + StellaOpsScopeRequirement requirement, + IReadOnlyCollection principalScopes, + ICollection missingScopes) + { + var resolvedDefaultScopes = defaultScopes as IReadOnlyList ?? 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, diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs index da5b7e7d5..e63ed2147 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOpsScopeRequirement.cs @@ -16,7 +16,11 @@ public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement /// Initialises a new instance of the class. /// /// Scopes that satisfy the requirement. - public StellaOpsScopeRequirement(IEnumerable scopes) + /// + /// When , every required scope must be present. + /// When , any one required scope satisfies the requirement. + /// + public StellaOpsScopeRequirement(IEnumerable 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; } /// /// Gets the required scopes. /// public IReadOnlyCollection RequiredScopes { get; } + + /// + /// Gets a value indicating whether all listed scopes are required. + /// + public bool RequireAllScopes { get; } } diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs new file mode 100644 index 000000000..d3a72136a --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/AocCompatibilityEndpoints.cs @@ -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(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(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(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(StringComparer.Ordinal) + { + ["findingId"] = inputValue, + ["decision"] = "warn" + }) + }, + isComplete = true, + validationErrors = Array.Empty(), + 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(StringComparer.Ordinal) + { + ["docker-hub"] = 12, + ["github-packages"] = 11 + }, + byReason = new Dictionary(StringComparer.Ordinal) + { + ["schema_invalid"] = 9, + ["missing_required_fields"] = 8, + ["hash_mismatch"] = 6 + } + }, + provenanceCompliance = new + { + percentage = 99.1, + bySource = new Dictionary(StringComparer.Ordinal) + { + ["docker-hub"] = 99.5, + ["github-packages"] = 98.7 + } + }, + deduplicationMetrics = new + { + rate = 12.4, + bySource = new Dictionary(StringComparer.Ordinal) + { + ["docker-hub"] = 10.8, + ["github-packages"] = 14.1 + } + }, + latencyMetrics = new + { + p50Ms = 122, + p95Ms = 301, + p99Ms = 410, + bySource = new Dictionary(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? 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 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 Details); +} diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs new file mode 100644 index 000000000..ad81ec866 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/ConsoleCompatibilityEndpoints.cs @@ -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); +} diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index 30ec17849..490abd461 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -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(); diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs new file mode 100644 index 000000000..9db6fe4f8 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/CompatibilityEndpointsTests.cs @@ -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 +{ + 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(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(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(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(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(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(TestContext.Current.CancellationToken); + Assert.True(retryPayload.GetProperty("success").GetBoolean()); + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs index a3282aeb4..3368b673b 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/QuotaEndpointsTests.cs @@ -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()); } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task QuotaPolicies_AcceptLegacyOrchQuotaScope() + { + var policyProvider = factory.Services.GetRequiredService(); + + 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()).RequiredScopes; + var readRequirement = Assert.Single(readPolicy!.Requirements.OfType()); + var adminRequirement = Assert.Single(adminPolicy!.Requirements.OfType()); + 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); + } } diff --git a/src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs b/src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs new file mode 100644 index 000000000..9a01a91ca --- /dev/null +++ b/src/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cs @@ -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 TrustWeightStates = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary 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(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 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 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 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 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 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 Thresholds, + bool Enabled, + int GracePeriodHours); + + private sealed record StalenessThresholdRecord( + string Level, + int AgeDays, + string Severity, + List 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? Weights); + +public sealed record StalenessConfigWriteModel +{ + public string? DataType { get; init; } + public IReadOnlyList? 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? Actions { get; init; } +} + +public sealed record StalenessActionWriteModel +{ + public string? Type { get; init; } + public string? Message { get; init; } + public string[]? Channels { get; init; } +} diff --git a/src/Policy/StellaOps.Policy.Gateway/Program.cs b/src/Policy/StellaOps.Policy.Gateway/Program.cs index db98ece35..93dd5ad1a 100644 --- a/src/Policy/StellaOps.Policy.Gateway/Program.cs +++ b/src/Policy/StellaOps.Policy.Gateway/Program.cs @@ -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(); diff --git a/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs new file mode 100644 index 000000000..e15132c23 --- /dev/null +++ b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/GovernanceCompatibilityEndpointsTests.cs @@ -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 +{ + 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(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(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(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(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(TestContext.Current.CancellationToken); + Assert.True(status.ValueKind == JsonValueKind.Array); + Assert.True(status.GetArrayLength() > 0); + } +} diff --git a/src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs b/src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs new file mode 100644 index 000000000..a9f2ae526 --- /dev/null +++ b/src/Signals/StellaOps.Signals/CompatibilityApiV1Endpoints.cs @@ -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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 + { + ["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 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() + .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 Payload, + string? CorrelationId, + string? ArtifactRef, + IReadOnlyCollection TriggeredActions, + string ReceivedAt, + string? ProcessedAt, + string? Error); +} diff --git a/src/Signals/StellaOps.Signals/Program.cs b/src/Signals/StellaOps.Signals/Program.cs index 8713d7af7..7a944bc54 100644 --- a/src/Signals/StellaOps.Signals/Program.cs +++ b/src/Signals/StellaOps.Signals/Program.cs @@ -1024,6 +1024,8 @@ signalsGroup.MapPost("/reachability/recompute", async Task ( } }).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 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) diff --git a/src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs b/src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs new file mode 100644 index 000000000..5c3fd048a --- /dev/null +++ b/src/Signals/__Tests/StellaOps.Signals.Tests/ProgramCompatibilityTests.cs @@ -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); + } +}