Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created CycloneDX and SPDX SBOM files for both reachable and unreachable images. - Added symbols.json detailing function entry and sink points in the WordPress code. - Included runtime traces for function calls in both reachable and unreachable scenarios. - Developed OpenVEX files indicating vulnerability status and justification for both cases. - Updated README for evaluator harness to guide integration with scanner output.
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 to stabilise Authority auth surfaces before final verification + publish. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
|
||||
| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
|
||||
| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⛔ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
|
||||
| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 landed 2025-10-21). | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
|
||||
| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 is done, limiter telemetry just awaits the updated Authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
|
||||
| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002; scoped DI work is complete. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
|
||||
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
|
||||
| PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
|
||||
| PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
@@ -8,18 +11,55 @@ namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
/// </summary>
|
||||
public static class AuthoritySecretHasher
|
||||
{
|
||||
private static ICryptoHash? configuredHash;
|
||||
private static string defaultAlgorithm = HashAlgorithms.Sha256;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable SHA-256 hash for the provided secret.
|
||||
/// Configures the shared crypto hash service used for secret hashing.
|
||||
/// </summary>
|
||||
public static string ComputeHash(string secret)
|
||||
public static void Configure(ICryptoHash hash, string? algorithmId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
Volatile.Write(ref configuredHash, hash);
|
||||
if (!string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
defaultAlgorithm = NormalizeAlgorithm(algorithmId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable hash for the provided secret using the configured crypto provider.
|
||||
/// </summary>
|
||||
public static string ComputeHash(string secret, string? algorithmId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(algorithmId)
|
||||
? defaultAlgorithm
|
||||
: NormalizeAlgorithm(algorithmId);
|
||||
|
||||
var hasher = Volatile.Read(ref configuredHash);
|
||||
if (hasher is not null)
|
||||
{
|
||||
var digest = hasher.ComputeHash(Encoding.UTF8.GetBytes(secret), algorithm);
|
||||
return Convert.ToBase64String(digest);
|
||||
}
|
||||
|
||||
if (!string.Equals(algorithm, HashAlgorithms.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Authority secret hasher is not configured for the requested algorithm.");
|
||||
}
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(secret));
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithm(string algorithmId)
|
||||
=> string.IsNullOrWhiteSpace(algorithmId)
|
||||
? HashAlgorithms.Sha256
|
||||
: algorithmId.Trim().ToUpperInvariant();
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderKeyThumbprint { get; set; }
|
||||
|
||||
[BsonElement("senderCertificateHex")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderCertificateHex { get; set; }
|
||||
|
||||
[BsonElement("senderNonce")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderNonce { get; set; }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
@@ -155,8 +157,8 @@ public sealed class ConsoleEndpointsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
|
||||
{
|
||||
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
@@ -189,8 +191,118 @@ public sealed class ConsoleEndpointsTests
|
||||
|
||||
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect");
|
||||
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
|
||||
Assert.Equal(2, events.Count);
|
||||
}
|
||||
Assert.Equal(2, events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VulnerabilityFindings_ReturnsSamplePayload()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.GetAsync("/console/vuln/findings?severity=high");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var items = json.RootElement.GetProperty("items");
|
||||
Assert.True(items.GetArrayLength() >= 1);
|
||||
Assert.Equal("CVE-2024-12345", items[0].GetProperty("coordinates").GetProperty("advisoryId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VulnerabilityFindingDetail_ReturnsExpandedDocument()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.GetAsync("/console/vuln/tenant-default:advisory-ai:sha256:5d1a");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var summary = json.RootElement.GetProperty("summary");
|
||||
Assert.Equal("tenant-default:advisory-ai:sha256:5d1a", summary.GetProperty("findingId").GetString());
|
||||
Assert.Equal("reachable", summary.GetProperty("reachability").GetProperty("status").GetString());
|
||||
var detailReachability = json.RootElement.GetProperty("reachability");
|
||||
Assert.Equal("reachable", detailReachability.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VulnerabilityTicket_ReturnsDeterministicPayload()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var payload = new ConsoleVulnerabilityTicketRequest(
|
||||
Selection: new[] { "tenant-default:advisory-ai:sha256:5d1a" },
|
||||
TargetSystem: "servicenow",
|
||||
Metadata: new Dictionary<string, string> { ["assignmentGroup"] = "runtime-security" });
|
||||
|
||||
var response = await client.PostAsJsonAsync("/console/vuln/tickets", payload);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.StartsWith("console-ticket::tenant-default::", json.RootElement.GetProperty("ticketId").GetString());
|
||||
Assert.Equal("servicenow", payload.TargetSystem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VexStatements_ReturnsSampleRows()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
|
||||
var sink = new RecordingAuthEventSink();
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
|
||||
|
||||
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
|
||||
accessor.Principal = CreatePrincipal(
|
||||
tenant: "tenant-default",
|
||||
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.VexRead },
|
||||
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
|
||||
|
||||
var client = app.CreateTestClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.GetAsync("/console/vex/statements?advisoryId=CVE-2024-12345");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var items = json.RootElement.GetProperty("items");
|
||||
Assert.True(items.GetArrayLength() >= 1);
|
||||
Assert.Equal("CVE-2024-12345", items[0].GetProperty("advisoryId").GetString());
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
string tenant,
|
||||
@@ -259,9 +371,10 @@ public sealed class ConsoleEndpointsTests
|
||||
builder.Services.AddSingleton<TimeProvider>(timeProvider);
|
||||
builder.Services.AddSingleton<IAuthEventSink>(sink);
|
||||
builder.Services.AddSingleton<IAuthorityTenantCatalog>(new FakeTenantCatalog(tenants));
|
||||
builder.Services.AddSingleton<TestPrincipalAccessor>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
|
||||
builder.Services.AddSingleton<TestPrincipalAccessor>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
|
||||
builder.Services.AddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
|
||||
|
||||
var authBuilder = builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -177,16 +177,17 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var auditSink = new TestAuthEventSink();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
clientStore,
|
||||
registry,
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
clock,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
clientStore,
|
||||
registry,
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
clock,
|
||||
TestActivitySource,
|
||||
TestInstruments.Meter,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
|
||||
var transaction = new OpenIddictServerTransaction
|
||||
{
|
||||
|
||||
@@ -30,8 +30,10 @@ public sealed class AuthorityJwksServiceTests
|
||||
var registry = new TestRegistry(provider);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-30T12:00:00Z"));
|
||||
var hash = CryptoHashFactory.CreateDefault();
|
||||
var service = new AuthorityJwksService(
|
||||
registry,
|
||||
hash,
|
||||
NullLogger<AuthorityJwksService>.Instance,
|
||||
cache,
|
||||
clock,
|
||||
@@ -64,8 +66,10 @@ public sealed class AuthorityJwksServiceTests
|
||||
var registry = new TestRegistry(provider);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-30T12:00:00Z"));
|
||||
var hash = CryptoHashFactory.CreateDefault();
|
||||
var service = new AuthorityJwksService(
|
||||
registry,
|
||||
hash,
|
||||
NullLogger<AuthorityJwksService>.Instance,
|
||||
cache,
|
||||
clock,
|
||||
|
||||
@@ -48,7 +48,7 @@ public sealed class KmsAuthoritySigningKeySourceTests
|
||||
var signingKey = source.Load(request);
|
||||
|
||||
Assert.Equal(CryptoSigningKeyKind.Raw, signingKey.Kind);
|
||||
Assert.Equal(material.KeyId, signingKey.Reference.KeyId);
|
||||
Assert.Equal(request.KeyId, signingKey.Reference.KeyId);
|
||||
Assert.True(signingKey.PrivateKey.Length > 0);
|
||||
Assert.True(signingKey.PublicKey.Length > 0);
|
||||
Assert.Equal(material.VersionId, signingKey.Metadata[KmsAuthoritySigningKeySource.KmsMetadataKeys.Version]);
|
||||
|
||||
@@ -37,10 +37,41 @@ internal static class ConsoleEndpointExtensions
|
||||
.WithName("ConsoleProfile")
|
||||
.WithSummary("Return the authenticated principal profile metadata.");
|
||||
|
||||
group.MapPost("/token/introspect", IntrospectToken)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
|
||||
.WithName("ConsoleTokenIntrospect")
|
||||
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
|
||||
group.MapPost("/token/introspect", IntrospectToken)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
|
||||
.WithName("ConsoleTokenIntrospect")
|
||||
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
|
||||
|
||||
var vulnGroup = group.MapGroup("/vuln")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
|
||||
StellaOpsScopes.UiRead,
|
||||
StellaOpsScopes.AdvisoryRead,
|
||||
StellaOpsScopes.VexRead));
|
||||
|
||||
vulnGroup.MapGet("/findings", GetVulnerabilityFindings)
|
||||
.WithName("ConsoleVulnerabilityFindings")
|
||||
.WithSummary("List tenant-scoped vulnerability findings with policy/VEX metadata.");
|
||||
|
||||
vulnGroup.MapGet("/{findingId}", GetVulnerabilityFindingById)
|
||||
.WithName("ConsoleVulnerabilityFindingDetail")
|
||||
.WithSummary("Return the full finding document, including evidence and policy overlays.");
|
||||
|
||||
vulnGroup.MapPost("/tickets", CreateVulnerabilityTicket)
|
||||
.WithName("ConsoleVulnerabilityTickets")
|
||||
.WithSummary("Generate a signed payload payload for external ticketing workflows.");
|
||||
|
||||
var vexGroup = group.MapGroup("/vex")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
|
||||
StellaOpsScopes.UiRead,
|
||||
StellaOpsScopes.VexRead));
|
||||
|
||||
vexGroup.MapGet("/statements", GetVexStatements)
|
||||
.WithName("ConsoleVexStatements")
|
||||
.WithSummary("List VEX statements impacting the tenant.");
|
||||
|
||||
vexGroup.MapGet("/events", StreamVexEvents)
|
||||
.WithName("ConsoleVexEvents")
|
||||
.WithSummary("Server-sent events feed for live VEX updates (placeholder).");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTenants(
|
||||
@@ -134,11 +165,11 @@ internal static class ConsoleEndpointExtensions
|
||||
return Results.Ok(profile);
|
||||
}
|
||||
|
||||
private static async Task<IResult> IntrospectToken(
|
||||
HttpContext httpContext,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
private static async Task<IResult> IntrospectToken(
|
||||
HttpContext httpContext,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
@@ -152,21 +183,214 @@ internal static class ConsoleEndpointExtensions
|
||||
|
||||
var introspection = BuildTokenIntrospection(principal, timeProvider);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.token.introspect",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(
|
||||
("token.active", introspection.Active ? "true" : "false"),
|
||||
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
|
||||
("tenant.resolved", introspection.Tenant)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(introspection);
|
||||
}
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.token.introspect",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(
|
||||
("token.active", introspection.Active ? "true" : "false"),
|
||||
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
|
||||
("tenant.resolved", introspection.Tenant)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(introspection);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetVulnerabilityFindings(
|
||||
HttpContext httpContext,
|
||||
IConsoleWorkspaceService workspaceService,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(workspaceService);
|
||||
|
||||
var tenant = TenantHeaderFilter.GetTenant(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.findings",
|
||||
AuthEventOutcome.Failure,
|
||||
"tenant_header_missing",
|
||||
BuildProperties(("tenant.header", null)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
|
||||
}
|
||||
|
||||
var query = BuildVulnerabilityQuery(httpContext.Request);
|
||||
var response = await workspaceService.SearchFindingsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.findings",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetVulnerabilityFindingById(
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
IConsoleWorkspaceService workspaceService,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(workspaceService);
|
||||
|
||||
var tenant = TenantHeaderFilter.GetTenant(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.finding",
|
||||
AuthEventOutcome.Failure,
|
||||
"tenant_header_missing",
|
||||
BuildProperties(("tenant.header", null)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
|
||||
}
|
||||
|
||||
var detail = await workspaceService.GetFindingAsync(tenant, findingId, cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.finding",
|
||||
AuthEventOutcome.Failure,
|
||||
"finding_not_found",
|
||||
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.NotFound(new { error = "finding_not_found", message = $"Finding '{findingId}' not found." });
|
||||
}
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.finding",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(detail);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateVulnerabilityTicket(
|
||||
HttpContext httpContext,
|
||||
ConsoleVulnerabilityTicketRequest request,
|
||||
IConsoleWorkspaceService workspaceService,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(workspaceService);
|
||||
|
||||
if (request is null || request.Selection.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "At least one finding must be selected." });
|
||||
}
|
||||
|
||||
var tenant = TenantHeaderFilter.GetTenant(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.ticket",
|
||||
AuthEventOutcome.Failure,
|
||||
"tenant_header_missing",
|
||||
BuildProperties(("tenant.header", null)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
|
||||
}
|
||||
|
||||
var ticket = await workspaceService.CreateTicketAsync(tenant, request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vuln.ticket",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(
|
||||
("tenant.resolved", tenant),
|
||||
("ticket.id", ticket.TicketId),
|
||||
("ticket.selection.count", request.Selection.Count.ToString(CultureInfo.InvariantCulture))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(ticket);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetVexStatements(
|
||||
HttpContext httpContext,
|
||||
IConsoleWorkspaceService workspaceService,
|
||||
TimeProvider timeProvider,
|
||||
IAuthEventSink auditSink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(workspaceService);
|
||||
|
||||
var tenant = TenantHeaderFilter.GetTenant(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vex.statements",
|
||||
AuthEventOutcome.Failure,
|
||||
"tenant_header_missing",
|
||||
BuildProperties(("tenant.header", null)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
|
||||
}
|
||||
|
||||
var query = BuildVexQuery(httpContext.Request);
|
||||
var response = await workspaceService.GetVexStatementsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.vex.statements",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static IResult StreamVexEvents() =>
|
||||
Results.StatusCode(StatusCodes.Status501NotImplemented);
|
||||
|
||||
private static ConsoleProfileResponse BuildProfile(ClaimsPrincipal principal, TimeProvider timeProvider)
|
||||
{
|
||||
@@ -231,9 +455,9 @@ internal static class ConsoleEndpointExtensions
|
||||
FreshAuth: freshAuth);
|
||||
}
|
||||
|
||||
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
|
||||
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
|
||||
if (flag is not null && bool.TryParse(flag.Value, out var freshFlag))
|
||||
{
|
||||
if (freshFlag)
|
||||
@@ -254,9 +478,67 @@ internal static class ConsoleEndpointExtensions
|
||||
return authTime.Value.Add(ttl) > now;
|
||||
}
|
||||
|
||||
const int defaultFreshAuthWindowSeconds = 300;
|
||||
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
|
||||
}
|
||||
const int defaultFreshAuthWindowSeconds = 300;
|
||||
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
|
||||
}
|
||||
|
||||
private static ConsoleVulnerabilityQuery BuildVulnerabilityQuery(HttpRequest request)
|
||||
{
|
||||
var builder = new ConsoleVulnerabilityQueryBuilder()
|
||||
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
|
||||
.SetPageToken(request.Query.TryGetValue("pageToken", out var tokenValues) ? tokenValues.FirstOrDefault() : null)
|
||||
.AddSeverity(ReadMulti(request, "severity"))
|
||||
.AddPolicyBadges(ReadMulti(request, "policyBadge"))
|
||||
.AddReachability(ReadMulti(request, "reachability"))
|
||||
.AddProducts(ReadMulti(request, "product"))
|
||||
.AddVexStates(ReadMulti(request, "vexState"));
|
||||
|
||||
var search = request.Query.TryGetValue("search", out var searchValues)
|
||||
? searchValues
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.SelectMany(value => value!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
: Array.Empty<string>();
|
||||
|
||||
builder.AddSearchTerms(search);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static ConsoleVexQuery BuildVexQuery(HttpRequest request)
|
||||
{
|
||||
var builder = new ConsoleVexQueryBuilder()
|
||||
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
|
||||
.SetPageToken(request.Query.TryGetValue("pageToken", out var pageValues) ? pageValues.FirstOrDefault() : null)
|
||||
.AddAdvisories(ReadMulti(request, "advisoryId"))
|
||||
.AddTypes(ReadMulti(request, "statementType"))
|
||||
.AddStates(ReadMulti(request, "state"));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadMulti(HttpRequest request, string key)
|
||||
{
|
||||
if (!request.Query.TryGetValue(key, out var values))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.SelectMany(value => value!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Where(value => value.Length > 0);
|
||||
}
|
||||
|
||||
private static int ParseInt(StringValues values, int fallback)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
|
||||
? number
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractRoles(ClaimsPrincipal principal)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Console;
|
||||
|
||||
internal sealed record ConsoleFacetBucket(string Value, int Count);
|
||||
|
||||
internal sealed record ConsoleFacetDistribution(
|
||||
IReadOnlyList<ConsoleFacetBucket> Severity,
|
||||
IReadOnlyList<ConsoleFacetBucket> PolicyBadge,
|
||||
IReadOnlyList<ConsoleFacetBucket> Reachability);
|
||||
|
||||
internal sealed record ConsoleFindingTimestamps(
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastSeen,
|
||||
DateTimeOffset? VexLastUpdated);
|
||||
|
||||
internal sealed record ConsoleVulnCoordinates(
|
||||
string AdvisoryId,
|
||||
string Package,
|
||||
string Component,
|
||||
string Image);
|
||||
|
||||
internal sealed record ConsoleVulnVexSummary(
|
||||
string StatementId,
|
||||
string State,
|
||||
string? Justification);
|
||||
|
||||
internal sealed record ConsoleReachabilitySummary(
|
||||
string Status,
|
||||
DateTimeOffset? LastObserved,
|
||||
string? SignalsVersion);
|
||||
|
||||
internal sealed record ConsoleReachabilityDetail(
|
||||
string Status,
|
||||
IReadOnlyList<string> CallPathSamples,
|
||||
DateTimeOffset? LastObserved,
|
||||
string? SignalsVersion);
|
||||
|
||||
internal sealed record ConsoleVulnEvidenceSummary(
|
||||
string? SbomDigest,
|
||||
string? PolicyRunId,
|
||||
string? AttestationId);
|
||||
|
||||
internal sealed record ConsoleEvidenceDetail(
|
||||
ConsoleVulnEvidenceSummary Summary,
|
||||
IReadOnlyList<string> ComponentPath,
|
||||
IReadOnlyList<ConsoleAttestationReference> Attestations);
|
||||
|
||||
internal sealed record ConsoleAttestationReference(
|
||||
string Type,
|
||||
string AttestationId,
|
||||
string Signer,
|
||||
string BundleDigest);
|
||||
|
||||
internal sealed record ConsolePolicyBadge(
|
||||
string PolicyId,
|
||||
string Verdict,
|
||||
string? ExplainUrl);
|
||||
|
||||
internal sealed record ConsoleVulnVexDetail(
|
||||
string StatementId,
|
||||
string State,
|
||||
string Justification,
|
||||
string? ImpactStatement,
|
||||
IReadOnlyList<ConsoleRemediation> Remediations);
|
||||
|
||||
internal sealed record ConsoleRemediation(
|
||||
string Type,
|
||||
string Description,
|
||||
DateTimeOffset? Deadline);
|
||||
|
||||
internal sealed record ConsoleVulnerabilityFinding(
|
||||
string Tenant,
|
||||
string FindingId,
|
||||
ConsoleVulnCoordinates Coordinates,
|
||||
string Summary,
|
||||
string Severity,
|
||||
double? Cvss,
|
||||
bool Kev,
|
||||
string PolicyBadge,
|
||||
ConsoleVulnVexSummary? Vex,
|
||||
ConsoleReachabilitySummary? Reachability,
|
||||
ConsoleVulnEvidenceSummary? Evidence,
|
||||
ConsoleFindingTimestamps Timestamps);
|
||||
|
||||
internal sealed record ConsoleVulnerabilityFindingDetail(
|
||||
ConsoleVulnerabilityFinding Summary,
|
||||
string Description,
|
||||
IReadOnlyList<string> References,
|
||||
IReadOnlyList<ConsolePolicyBadge> PolicyBadges,
|
||||
ConsoleVulnVexDetail? Vex,
|
||||
ConsoleReachabilityDetail? Reachability,
|
||||
ConsoleEvidenceDetail Evidence);
|
||||
|
||||
internal sealed record ConsoleVulnerabilitySearchResponse(
|
||||
IReadOnlyList<ConsoleVulnerabilityFinding> Items,
|
||||
ConsoleFacetDistribution Facets,
|
||||
string? NextPageToken);
|
||||
|
||||
internal sealed record ConsoleVulnerabilityQuery(
|
||||
IReadOnlyList<string> Severity,
|
||||
IReadOnlyList<string> PolicyBadges,
|
||||
IReadOnlyList<string> Reachability,
|
||||
IReadOnlyList<string> Products,
|
||||
IReadOnlyList<string> VexStates,
|
||||
IReadOnlyList<string> SearchTerms,
|
||||
int PageSize,
|
||||
string? PageToken);
|
||||
|
||||
internal sealed record ConsoleVulnerabilityTicketRequest(
|
||||
IReadOnlyList<string> Selection,
|
||||
string TargetSystem,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
internal sealed record ConsoleTicketPayload(
|
||||
string Version,
|
||||
string Tenant,
|
||||
IReadOnlyList<ConsoleTicketSelection> Findings,
|
||||
string PolicyBadge,
|
||||
string VexSummary,
|
||||
IReadOnlyList<ConsoleTicketAttachment> Attachments);
|
||||
|
||||
internal sealed record ConsoleTicketSelection(string FindingId, string Severity);
|
||||
|
||||
internal sealed record ConsoleTicketAttachment(
|
||||
string Type,
|
||||
string Name,
|
||||
string Digest,
|
||||
string ContentType,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
internal sealed record ConsoleVulnerabilityTicketResponse(
|
||||
string TicketId,
|
||||
ConsoleTicketPayload Payload,
|
||||
string AuditEventId);
|
||||
|
||||
internal sealed record ConsoleVexStatementSummary(
|
||||
string Tenant,
|
||||
string StatementId,
|
||||
string AdvisoryId,
|
||||
string Product,
|
||||
string State,
|
||||
string Justification,
|
||||
string StatementType,
|
||||
DateTimeOffset LastUpdated,
|
||||
ConsoleVexSourceMetadata Source);
|
||||
|
||||
internal sealed record ConsoleVexSourceMetadata(
|
||||
string Type,
|
||||
string? ModelBuild,
|
||||
double? Confidence);
|
||||
|
||||
internal sealed record ConsoleVexStatementPage(
|
||||
IReadOnlyList<ConsoleVexStatementSummary> Items,
|
||||
string? NextPageToken);
|
||||
|
||||
internal sealed record ConsoleVexQuery(
|
||||
IReadOnlyList<string> AdvisoryIds,
|
||||
IReadOnlyList<string> StatementTypes,
|
||||
IReadOnlyList<string> States,
|
||||
int PageSize,
|
||||
string? PageToken);
|
||||
|
||||
internal interface IConsoleWorkspaceService
|
||||
{
|
||||
Task<ConsoleVulnerabilitySearchResponse> SearchFindingsAsync(
|
||||
string tenant,
|
||||
ConsoleVulnerabilityQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ConsoleVulnerabilityFindingDetail?> GetFindingAsync(
|
||||
string tenant,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ConsoleVulnerabilityTicketResponse> CreateTicketAsync(
|
||||
string tenant,
|
||||
ConsoleVulnerabilityTicketRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ConsoleVexStatementPage> GetVexStatementsAsync(
|
||||
string tenant,
|
||||
ConsoleVexQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class ConsoleVulnerabilityQueryBuilder
|
||||
{
|
||||
private readonly HashSet<string> _severity = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _policy = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _reachability = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _products = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _vexStates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _searchTerms = new(StringComparer.OrdinalIgnoreCase);
|
||||
private int _pageSize = 50;
|
||||
private string? _pageToken;
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder SetPageSize(int value)
|
||||
{
|
||||
_pageSize = Math.Clamp(value, 1, 200);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder SetPageToken(string? token)
|
||||
{
|
||||
_pageToken = string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder AddSeverity(IEnumerable<string> values)
|
||||
{
|
||||
_severity.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder AddPolicyBadges(IEnumerable<string> values)
|
||||
{
|
||||
_policy.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder AddReachability(IEnumerable<string> values)
|
||||
{
|
||||
_reachability.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder AddProducts(IEnumerable<string> values)
|
||||
{
|
||||
_products.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder AddVexStates(IEnumerable<string> values)
|
||||
{
|
||||
_vexStates.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQueryBuilder AddSearchTerms(IEnumerable<string> values)
|
||||
{
|
||||
_searchTerms.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVulnerabilityQuery Build() =>
|
||||
new(
|
||||
_severity.ToImmutableArray(),
|
||||
_policy.ToImmutableArray(),
|
||||
_reachability.ToImmutableArray(),
|
||||
_products.ToImmutableArray(),
|
||||
_vexStates.ToImmutableArray(),
|
||||
_searchTerms.ToImmutableArray(),
|
||||
_pageSize,
|
||||
_pageToken);
|
||||
}
|
||||
|
||||
internal sealed class ConsoleVexQueryBuilder
|
||||
{
|
||||
private readonly HashSet<string> _advisories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _types = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
private int _pageSize = 50;
|
||||
private string? _pageToken;
|
||||
|
||||
public ConsoleVexQueryBuilder SetPageSize(int value)
|
||||
{
|
||||
_pageSize = Math.Clamp(value, 1, 200);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVexQueryBuilder SetPageToken(string? token)
|
||||
{
|
||||
_pageToken = string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVexQueryBuilder AddAdvisories(IEnumerable<string> values)
|
||||
{
|
||||
_advisories.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVexQueryBuilder AddTypes(IEnumerable<string> values)
|
||||
{
|
||||
_types.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVexQueryBuilder AddStates(IEnumerable<string> values)
|
||||
{
|
||||
_states.UnionWith(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ConsoleVexQuery Build() =>
|
||||
new(
|
||||
_advisories.ToImmutableArray(),
|
||||
_types.ToImmutableArray(),
|
||||
_states.ToImmutableArray(),
|
||||
_pageSize,
|
||||
_pageToken);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Authority.Console;
|
||||
|
||||
internal sealed class ConsoleWorkspaceSampleService : IConsoleWorkspaceService
|
||||
{
|
||||
private static readonly ImmutableArray<ConsoleVulnerabilityFindingDetail> SampleFindings;
|
||||
private static readonly ImmutableArray<ConsoleVexStatementSummary> SampleStatements;
|
||||
|
||||
static ConsoleWorkspaceSampleService()
|
||||
{
|
||||
var finding1Summary = new ConsoleVulnerabilityFinding(
|
||||
Tenant: "tenant-default",
|
||||
FindingId: "tenant-default:advisory-ai:sha256:5d1a",
|
||||
Coordinates: new ConsoleVulnCoordinates(
|
||||
AdvisoryId: "CVE-2024-12345",
|
||||
Package: "pkg:npm/jsonwebtoken@9.0.2",
|
||||
Component: "jwt-auth-service",
|
||||
Image: "registry.local/ops/auth:2025.10.0"),
|
||||
Summary: "jsonwebtoken <10.0.0 allows algorithm downgrade.",
|
||||
Severity: "high",
|
||||
Cvss: 8.1,
|
||||
Kev: true,
|
||||
PolicyBadge: "fail",
|
||||
Vex: new ConsoleVulnVexSummary(
|
||||
StatementId: "vex:tenant-default:jwt-auth:5d1a",
|
||||
State: "under_investigation",
|
||||
Justification: "Advisory AI flagged reachable path via Scheduler run 42."),
|
||||
Reachability: new ConsoleReachabilitySummary(
|
||||
Status: "reachable",
|
||||
LastObserved: DateTimeOffset.Parse("2025-11-07T23:11:04Z"),
|
||||
SignalsVersion: "signals-2025.310.1"),
|
||||
Evidence: new ConsoleVulnEvidenceSummary(
|
||||
SbomDigest: "sha256:6c81f2bbd8bd7336f197f3f68fba2f76d7287dd1a5e2a0f0e9f14f23f3c2f917",
|
||||
PolicyRunId: "policy-run::2025-11-07::ca9f",
|
||||
AttestationId: "dsse://authority/attest/84a2"),
|
||||
Timestamps: new ConsoleFindingTimestamps(
|
||||
FirstSeen: DateTimeOffset.Parse("2025-10-31T04:22:18Z"),
|
||||
LastSeen: DateTimeOffset.Parse("2025-11-07T23:16:51Z"),
|
||||
VexLastUpdated: DateTimeOffset.Parse("2025-11-07T23:10:09Z")));
|
||||
|
||||
var finding2Summary = new ConsoleVulnerabilityFinding(
|
||||
Tenant: "tenant-default",
|
||||
FindingId: "tenant-default:advisory-ai:sha256:9bf4",
|
||||
Coordinates: new ConsoleVulnCoordinates(
|
||||
AdvisoryId: "GHSA-xxxx-yyyy-zzzz",
|
||||
Package: "pkg:docker/library/nginx@1.25.2",
|
||||
Component: "ingress-gateway",
|
||||
Image: "registry.local/ops/ingress:2025.09.1"),
|
||||
Summary: "Heap overflow in nginx HTTP/3 parsing.",
|
||||
Severity: "critical",
|
||||
Cvss: 9.8,
|
||||
Kev: false,
|
||||
PolicyBadge: "warn",
|
||||
Vex: new ConsoleVulnVexSummary(
|
||||
StatementId: "vex:tenant-default:ingress:9bf4",
|
||||
State: "not_affected",
|
||||
Justification: "component_not_present"),
|
||||
Reachability: new ConsoleReachabilitySummary(
|
||||
Status: "unknown",
|
||||
LastObserved: null,
|
||||
SignalsVersion: "signals-2025.309.0"),
|
||||
Evidence: new ConsoleVulnEvidenceSummary(
|
||||
SbomDigest: "sha256:99f1e2a7aa0f7c970dcb6674244f0bfb5f37148e3ee09fd4f925d3358dea2239",
|
||||
PolicyRunId: "policy-run::2025-11-06::b210",
|
||||
AttestationId: "dsse://authority/attest/1d34"),
|
||||
Timestamps: new ConsoleFindingTimestamps(
|
||||
FirstSeen: DateTimeOffset.Parse("2025-10-29T18:03:11Z"),
|
||||
LastSeen: DateTimeOffset.Parse("2025-11-07T10:45:03Z"),
|
||||
VexLastUpdated: DateTimeOffset.Parse("2025-11-06T18:44:00Z")));
|
||||
|
||||
SampleFindings = ImmutableArray.Create(
|
||||
new ConsoleVulnerabilityFindingDetail(
|
||||
Summary: finding1Summary,
|
||||
Description: "jsonwebtoken accepts untrusted algorithm overrides which allow downgrade attacks.",
|
||||
References: new[]
|
||||
{
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-12345",
|
||||
"https://github.com/auth0/node-jsonwebtoken/security/advisories/GHSA-45mw-4jw3-g2wg"
|
||||
},
|
||||
PolicyBadges: new[]
|
||||
{
|
||||
new ConsolePolicyBadge("policy://tenant-default/runtime-hardening", "fail", "https://console.local/policy/runs/policy-run::2025-11-07::ca9f")
|
||||
},
|
||||
Vex: new ConsoleVulnVexDetail(
|
||||
StatementId: "vex:tenant-default:jwt-auth:5d1a",
|
||||
State: "under_investigation",
|
||||
Justification: "Runtime telemetry confirmed exploitation path.",
|
||||
ImpactStatement: "Token exchange service remains exposed until patch 2025.11.2.",
|
||||
Remediations: new[]
|
||||
{
|
||||
new ConsoleRemediation("patch", "Upgrade jwt-auth-service to 2025.11.2.", DateTimeOffset.Parse("2025-11-12T00:00:00Z"))
|
||||
}),
|
||||
Reachability: new ConsoleReachabilityDetail(
|
||||
Status: "reachable",
|
||||
CallPathSamples: new[]
|
||||
{
|
||||
"api-gateway -> jwt-auth-service -> jsonwebtoken.verify"
|
||||
},
|
||||
LastObserved: DateTimeOffset.Parse("2025-11-07T23:11:04Z"),
|
||||
SignalsVersion: "signals-2025.310.1"),
|
||||
Evidence: new ConsoleEvidenceDetail(
|
||||
Summary: finding1Summary.Evidence!,
|
||||
ComponentPath: new[]
|
||||
{
|
||||
"/src/jwt-auth/package.json",
|
||||
"/src/jwt-auth/node_modules/jsonwebtoken"
|
||||
},
|
||||
Attestations: new[]
|
||||
{
|
||||
new ConsoleAttestationReference("scan-report", "dsse://authority/attest/84a2", "attestor@stella-ops.org", "sha256:e2bb5c7a0a8b2d16ff42e7f8decb4bb8be71ad0a1606dbc5d28be43675fbad32")
|
||||
})),
|
||||
new ConsoleVulnerabilityFindingDetail(
|
||||
Summary: finding2Summary,
|
||||
Description: "nginx HTTP/3 heap overflow affecting unpatched ingress nodes.",
|
||||
References: new[]
|
||||
{
|
||||
"https://security.nginx.org/announcements/2024/http3-overflow",
|
||||
},
|
||||
PolicyBadges: new[]
|
||||
{
|
||||
new ConsolePolicyBadge("policy://tenant-default/network-hardening", "warn", null)
|
||||
},
|
||||
Vex: new ConsoleVulnVexDetail(
|
||||
StatementId: "vex:tenant-default:ingress:9bf4",
|
||||
State: "not_affected",
|
||||
Justification: "component_not_present",
|
||||
ImpactStatement: "HTTP/3 disabled on ingress, exposure limited.",
|
||||
Remediations: Array.Empty<ConsoleRemediation>()),
|
||||
Reachability: new ConsoleReachabilityDetail(
|
||||
Status: "unknown",
|
||||
CallPathSamples: Array.Empty<string>(),
|
||||
LastObserved: null,
|
||||
SignalsVersion: "signals-2025.309.0"),
|
||||
Evidence: new ConsoleEvidenceDetail(
|
||||
Summary: finding2Summary.Evidence!,
|
||||
ComponentPath: new[]
|
||||
{
|
||||
"/charts/ingress/templates/deployment.yaml"
|
||||
},
|
||||
Attestations: new[]
|
||||
{
|
||||
new ConsoleAttestationReference("scan-report", "dsse://authority/attest/1d34", "attestor@stella-ops.org", "sha256:91e6dd2c1bbf9a4ac797e050d71bf7f1b958d1a0c27469364c44d8ed74bcb9dc")
|
||||
})));
|
||||
|
||||
SampleStatements = ImmutableArray.Create(
|
||||
new ConsoleVexStatementSummary(
|
||||
Tenant: "tenant-default",
|
||||
StatementId: "vex:tenant-default:jwt-auth:5d1a",
|
||||
AdvisoryId: "CVE-2024-12345",
|
||||
Product: "registry.local/ops/auth:2025.10.0",
|
||||
State: "under_investigation",
|
||||
Justification: "exploit_observed",
|
||||
StatementType: "advisory_ai",
|
||||
LastUpdated: DateTimeOffset.Parse("2025-11-07T23:10:09Z"),
|
||||
Source: new ConsoleVexSourceMetadata("advisory_ai", "aiai-console-2025-10-28", 0.74)),
|
||||
new ConsoleVexStatementSummary(
|
||||
Tenant: "tenant-default",
|
||||
StatementId: "vex:tenant-default:jwt-auth:5d1a",
|
||||
AdvisoryId: "CVE-2024-12345",
|
||||
Product: "registry.local/ops/auth:2025.10.0",
|
||||
State: "fixed",
|
||||
Justification: "solution_available",
|
||||
StatementType: "advisory_ai",
|
||||
LastUpdated: DateTimeOffset.Parse("2025-11-08T11:44:32Z"),
|
||||
Source: new ConsoleVexSourceMetadata("advisory_ai", "aiai-console-2025-10-28", 0.92)),
|
||||
new ConsoleVexStatementSummary(
|
||||
Tenant: "tenant-default",
|
||||
StatementId: "vex:tenant-default:ingress:9bf4",
|
||||
AdvisoryId: "GHSA-xxxx-yyyy-zzzz",
|
||||
Product: "registry.local/ops/ingress:2025.09.1",
|
||||
State: "not_affected",
|
||||
Justification: "component_not_present",
|
||||
StatementType: "excitor",
|
||||
LastUpdated: DateTimeOffset.Parse("2025-11-06T18:44:00Z"),
|
||||
Source: new ConsoleVexSourceMetadata("excitor", null, null)));
|
||||
}
|
||||
|
||||
public Task<ConsoleVulnerabilitySearchResponse> SearchFindingsAsync(
|
||||
string tenant,
|
||||
ConsoleVulnerabilityQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filtered = SampleFindings
|
||||
.Where(detail => IsTenantMatch(tenant, detail.Summary))
|
||||
.Where(detail => MatchesSeverity(detail, query))
|
||||
.Where(detail => MatchesPolicy(detail, query))
|
||||
.Where(detail => MatchesReachability(detail, query))
|
||||
.Where(detail => MatchesSearch(detail, query))
|
||||
.Take(query.PageSize)
|
||||
.Select(detail => detail.Summary)
|
||||
.ToImmutableArray();
|
||||
|
||||
var facets = BuildFacets(tenant);
|
||||
var response = new ConsoleVulnerabilitySearchResponse(filtered, facets, NextPageToken: null);
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<ConsoleVulnerabilityFindingDetail?> GetFindingAsync(
|
||||
string tenant,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = SampleFindings.FirstOrDefault(f =>
|
||||
string.Equals(f.Summary.FindingId, findingId, StringComparison.OrdinalIgnoreCase) &&
|
||||
IsTenantMatch(tenant, f.Summary));
|
||||
|
||||
return Task.FromResult(detail);
|
||||
}
|
||||
|
||||
public Task<ConsoleVulnerabilityTicketResponse> CreateTicketAsync(
|
||||
string tenant,
|
||||
ConsoleVulnerabilityTicketRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableArray<ConsoleTicketSelection> selection;
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
selection = ImmutableArray<ConsoleTicketSelection>.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
selection = request.Selection
|
||||
.Select(id => SampleFindings.FirstOrDefault(f => string.Equals(f.Summary.FindingId, id, StringComparison.OrdinalIgnoreCase)))
|
||||
.Where(detail => detail is not null)
|
||||
.Select(detail => new ConsoleTicketSelection(detail!.Summary.FindingId, detail.Summary.Severity))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
var ticketId = BuildTicketId(tenant, request.Selection);
|
||||
var attachmentName = $"console-ticket-{DateTimeOffset.UtcNow:yyyyMMdd}.json";
|
||||
var payload = new ConsoleTicketPayload(
|
||||
Version: "2025-11-01",
|
||||
Tenant: tenant,
|
||||
Findings: selection,
|
||||
PolicyBadge: selection.Any(sel => string.Equals(sel.Severity, "critical", StringComparison.OrdinalIgnoreCase)) ? "fail" : "warn",
|
||||
VexSummary: $"{selection.Length} findings included in ticket.",
|
||||
Attachments: new[]
|
||||
{
|
||||
new ConsoleTicketAttachment(
|
||||
Type: "json",
|
||||
Name: attachmentName,
|
||||
Digest: HashAttachmentName(attachmentName),
|
||||
ContentType: "application/json",
|
||||
ExpiresAt: DateTimeOffset.UtcNow.AddDays(7))
|
||||
});
|
||||
|
||||
var response = new ConsoleVulnerabilityTicketResponse(
|
||||
TicketId: ticketId,
|
||||
Payload: payload,
|
||||
AuditEventId: $"{ticketId}::audit");
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<ConsoleVexStatementPage> GetVexStatementsAsync(
|
||||
string tenant,
|
||||
ConsoleVexQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filtered = SampleStatements
|
||||
.Where(statement => string.Equals(statement.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(statement => MatchesAdvisory(statement, query))
|
||||
.Where(statement => MatchesState(statement, query))
|
||||
.Where(statement => MatchesType(statement, query))
|
||||
.Take(query.PageSize)
|
||||
.ToImmutableArray();
|
||||
|
||||
var page = new ConsoleVexStatementPage(filtered, NextPageToken: null);
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
||||
private static bool MatchesSeverity(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) =>
|
||||
query.Severity.Count == 0 ||
|
||||
query.Severity.Any(sev => string.Equals(sev, detail.Summary.Severity, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool MatchesPolicy(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) =>
|
||||
query.PolicyBadges.Count == 0 ||
|
||||
query.PolicyBadges.Any(badge => string.Equals(badge, detail.Summary.PolicyBadge, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool MatchesReachability(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query)
|
||||
{
|
||||
if (query.Reachability.Count == 0 || detail.Summary.Reachability is null)
|
||||
{
|
||||
return query.Reachability.Count == 0;
|
||||
}
|
||||
|
||||
return query.Reachability.Any(state =>
|
||||
string.Equals(state, detail.Summary.Reachability.Status, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool MatchesSearch(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query)
|
||||
{
|
||||
if (query.SearchTerms.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return query.SearchTerms.Any(term =>
|
||||
Contains(term, detail.Summary.FindingId) ||
|
||||
Contains(term, detail.Summary.Coordinates.AdvisoryId) ||
|
||||
Contains(term, detail.Summary.Coordinates.Component));
|
||||
}
|
||||
|
||||
private static bool MatchesAdvisory(ConsoleVexStatementSummary summary, ConsoleVexQuery query) =>
|
||||
query.AdvisoryIds.Count == 0 ||
|
||||
query.AdvisoryIds.Any(advisory => string.Equals(advisory, summary.AdvisoryId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool MatchesType(ConsoleVexStatementSummary summary, ConsoleVexQuery query) =>
|
||||
query.StatementTypes.Count == 0 ||
|
||||
query.StatementTypes.Any(type => string.Equals(type, summary.StatementType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool MatchesState(ConsoleVexStatementSummary summary, ConsoleVexQuery query) =>
|
||||
query.States.Count == 0 ||
|
||||
query.States.Any(state => string.Equals(state, summary.State, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool Contains(string term, string value) =>
|
||||
value?.IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private static bool IsTenantMatch(string tenant, ConsoleVulnerabilityFinding summary) =>
|
||||
string.Equals(summary.Tenant, tenant, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static ConsoleFacetDistribution BuildFacets(string tenant)
|
||||
{
|
||||
var findings = SampleFindings.Where(detail => IsTenantMatch(tenant, detail.Summary)).Select(detail => detail.Summary);
|
||||
return new ConsoleFacetDistribution(
|
||||
Severity: AggregateFacet(findings, finding => finding.Severity),
|
||||
PolicyBadge: AggregateFacet(findings, finding => finding.PolicyBadge),
|
||||
Reachability: AggregateFacet(findings, finding => finding.Reachability?.Status ?? "unknown"));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConsoleFacetBucket> AggregateFacet(
|
||||
IEnumerable<ConsoleVulnerabilityFinding> findings,
|
||||
Func<ConsoleVulnerabilityFinding, string> selector) =>
|
||||
findings
|
||||
.GroupBy(selector, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new ConsoleFacetBucket(group.Key ?? "unknown", group.Count()))
|
||||
.ToImmutableArray();
|
||||
|
||||
private static string BuildTicketId(string tenant, IEnumerable<string> selection)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var joined = string.Join("|", selection.Order());
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(joined));
|
||||
var prefix = Convert.ToHexString(hash[..8]).ToLowerInvariant();
|
||||
return $"console-ticket::{tenant}::{prefix}";
|
||||
}
|
||||
|
||||
private static string HashAttachmentName(string name)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(name));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -1,314 +1,319 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
internal sealed class AuthorityOpenApiDocumentProvider
|
||||
{
|
||||
private readonly string specificationPath;
|
||||
private readonly ILogger<AuthorityOpenApiDocumentProvider> logger;
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
private OpenApiDocumentSnapshot? cached;
|
||||
|
||||
public AuthorityOpenApiDocumentProvider(IWebHostEnvironment environment, ILogger<AuthorityOpenApiDocumentProvider> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml");
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<OpenApiDocumentSnapshot> GetDocumentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lastWriteUtc = GetLastWriteTimeUtc();
|
||||
var current = cached;
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
current = cached;
|
||||
lastWriteUtc = GetLastWriteTimeUtc();
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var snapshot = LoadSnapshot(lastWriteUtc);
|
||||
cached = snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime GetLastWriteTimeUtc()
|
||||
{
|
||||
var file = new FileInfo(specificationPath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath);
|
||||
}
|
||||
|
||||
return file.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc)
|
||||
{
|
||||
string yamlText;
|
||||
try
|
||||
{
|
||||
yamlText = File.ReadAllText(specificationPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
var yamlStream = new YamlStream();
|
||||
using (var reader = new StringReader(yamlText))
|
||||
{
|
||||
yamlStream.Load(reader);
|
||||
}
|
||||
|
||||
if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode)
|
||||
{
|
||||
throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node.");
|
||||
}
|
||||
|
||||
var (grants, scopes) = CollectGrantsAndScopes(rootNode);
|
||||
|
||||
if (!TryGetMapping(rootNode, "info", out var infoNode))
|
||||
{
|
||||
infoNode = new YamlMappingNode();
|
||||
rootNode.Children[new YamlScalarNode("info")] = infoNode;
|
||||
}
|
||||
|
||||
var serviceName = "authority";
|
||||
var buildVersion = ResolveBuildVersion();
|
||||
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);
|
||||
|
||||
var apiVersion = TryGetScalar(infoNode, "version", out var version)
|
||||
? version
|
||||
: "0.0.0";
|
||||
|
||||
var updatedYaml = WriteYaml(yamlStream);
|
||||
var json = ConvertYamlToJson(updatedYaml);
|
||||
var etag = CreateStrongEtag(json);
|
||||
|
||||
return new OpenApiDocumentSnapshot(
|
||||
serviceName,
|
||||
apiVersion,
|
||||
buildVersion,
|
||||
json,
|
||||
updatedYaml,
|
||||
etag,
|
||||
lastWriteUtc,
|
||||
grants,
|
||||
scopes);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<string> Grants, IReadOnlyList<string> Scopes) CollectGrantsAndScopes(YamlMappingNode root)
|
||||
{
|
||||
if (!TryGetMapping(root, "components", out var components) ||
|
||||
!TryGetMapping(components, "securitySchemes", out var securitySchemes))
|
||||
{
|
||||
return (Array.Empty<string>(), Array.Empty<string>());
|
||||
}
|
||||
|
||||
var grants = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var scopes = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var scheme in securitySchemes.Children.Values.OfType<YamlMappingNode>())
|
||||
{
|
||||
if (!TryGetMapping(scheme, "flows", out var flows))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var flowEntry in flows.Children)
|
||||
{
|
||||
if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var grant = NormalizeGrantName(flowNameNode.Value);
|
||||
if (grant is not null)
|
||||
{
|
||||
grants.Add(grant);
|
||||
}
|
||||
|
||||
if (TryGetMapping(flowMapping, "scopes", out var scopesMapping))
|
||||
{
|
||||
foreach (var scope in scopesMapping.Children.Keys.OfType<YamlScalarNode>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope.Value))
|
||||
{
|
||||
scopes.Add(scope.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flowMapping.Children.TryGetValue(new YamlScalarNode("refreshUrl"), out var refreshNode) &&
|
||||
refreshNode is YamlScalarNode refreshScalar && !string.IsNullOrWhiteSpace(refreshScalar.Value))
|
||||
{
|
||||
grants.Add("refresh_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
grants.Count == 0 ? Array.Empty<string>() : grants.ToArray(),
|
||||
scopes.Count == 0 ? Array.Empty<string>() : scopes.ToArray());
|
||||
}
|
||||
|
||||
private static string? NormalizeGrantName(string? flowName)
|
||||
=> flowName switch
|
||||
{
|
||||
null or "" => null,
|
||||
"authorizationCode" => "authorization_code",
|
||||
"clientCredentials" => "client_credentials",
|
||||
"password" => "password",
|
||||
"implicit" => "implicit",
|
||||
"deviceCode" => "device_code",
|
||||
_ => flowName
|
||||
};
|
||||
|
||||
private static void ApplyInfoMetadata(
|
||||
YamlMappingNode infoNode,
|
||||
string serviceName,
|
||||
string buildVersion,
|
||||
IReadOnlyList<string> grants,
|
||||
IReadOnlyList<string> scopes)
|
||||
{
|
||||
infoNode.Children[new YamlScalarNode("x-stella-service")] = new YamlScalarNode(serviceName);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-build-version")] = new YamlScalarNode(buildVersion);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-grant-types")] = CreateSequence(grants);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-scopes")] = CreateSequence(scopes);
|
||||
}
|
||||
|
||||
private static YamlSequenceNode CreateSequence(IEnumerable<string> values)
|
||||
{
|
||||
var sequence = new YamlSequenceNode();
|
||||
foreach (var value in values)
|
||||
{
|
||||
sequence.Add(new YamlScalarNode(value));
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private static bool TryGetMapping(YamlMappingNode node, string key, out YamlMappingNode mapping)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
if (entry.Value is YamlMappingNode mappingNode)
|
||||
{
|
||||
mapping = mappingNode;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
mapping = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetScalar(YamlMappingNode node, string key, out string value)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
if (entry.Value is YamlScalarNode valueNode)
|
||||
{
|
||||
value = valueNode.Value ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string WriteYaml(YamlStream yamlStream)
|
||||
{
|
||||
using var writer = new StringWriter(CultureInfo.InvariantCulture);
|
||||
yamlStream.Save(writer, assignAnchors: false);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static string ConvertYamlToJson(string yaml)
|
||||
{
|
||||
var deserializer = new DeserializerBuilder().Build();
|
||||
var yamlObject = deserializer.Deserialize(new StringReader(yaml));
|
||||
|
||||
var serializer = new SerializerBuilder()
|
||||
.JsonCompatible()
|
||||
.Build();
|
||||
|
||||
var json = serializer.Serialize(yamlObject);
|
||||
return string.IsNullOrWhiteSpace(json) ? "{}" : json.Trim();
|
||||
}
|
||||
|
||||
private static string CreateStrongEtag(string jsonRepresentation)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(jsonRepresentation));
|
||||
var hash = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
return $"\"{hash}\"";
|
||||
}
|
||||
|
||||
private static string ResolveBuildVersion()
|
||||
{
|
||||
var assembly = typeof(AuthorityOpenApiDocumentProvider).Assembly;
|
||||
var informational = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(informational))
|
||||
{
|
||||
return informational!;
|
||||
}
|
||||
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenApiDocumentSnapshot(
|
||||
string ServiceName,
|
||||
string ApiVersion,
|
||||
string BuildVersion,
|
||||
string Json,
|
||||
string Yaml,
|
||||
string ETag,
|
||||
DateTime LastWriteUtc,
|
||||
IReadOnlyList<string> GrantTypes,
|
||||
IReadOnlyList<string> Scopes);
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.IO;
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
using System.Linq;
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
using System.Text;
|
||||
|
||||
using System.Threading;
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using YamlDotNet.Core;
|
||||
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
|
||||
|
||||
internal sealed class AuthorityOpenApiDocumentProvider
|
||||
|
||||
{
|
||||
|
||||
private readonly string specificationPath;
|
||||
|
||||
private readonly ILogger<AuthorityOpenApiDocumentProvider> logger;
|
||||
|
||||
private readonly ICryptoHash hash;
|
||||
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
|
||||
private OpenApiDocumentSnapshot? cached;
|
||||
|
||||
|
||||
|
||||
public AuthorityOpenApiDocumentProvider(
|
||||
|
||||
IWebHostEnvironment environment,
|
||||
|
||||
ILogger<AuthorityOpenApiDocumentProvider> logger,
|
||||
|
||||
ICryptoHash hash)
|
||||
|
||||
{
|
||||
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
|
||||
|
||||
|
||||
specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml");
|
||||
|
||||
this.logger = logger;
|
||||
|
||||
this.hash = hash;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async ValueTask<OpenApiDocumentSnapshot> GetDocumentAsync(CancellationToken cancellationToken)
|
||||
|
||||
{
|
||||
|
||||
var lastWriteUtc = GetLastWriteTimeUtc();
|
||||
|
||||
var current = cached;
|
||||
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
|
||||
{
|
||||
|
||||
return current;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
|
||||
{
|
||||
|
||||
current = cached;
|
||||
|
||||
lastWriteUtc = GetLastWriteTimeUtc();
|
||||
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
|
||||
{
|
||||
|
||||
return current;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var snapshot = LoadSnapshot(lastWriteUtc);
|
||||
|
||||
cached = snapshot;
|
||||
|
||||
return snapshot;
|
||||
|
||||
}
|
||||
|
||||
finally
|
||||
|
||||
{
|
||||
|
||||
refreshLock.Release();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private DateTime GetLastWriteTimeUtc()
|
||||
|
||||
{
|
||||
|
||||
var file = new FileInfo(specificationPath);
|
||||
|
||||
if (!file.Exists)
|
||||
|
||||
{
|
||||
|
||||
throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return file.LastWriteTimeUtc;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc)
|
||||
|
||||
{
|
||||
|
||||
string yamlText;
|
||||
|
||||
try
|
||||
|
||||
{
|
||||
|
||||
yamlText = File.ReadAllText(specificationPath);
|
||||
|
||||
}
|
||||
|
||||
catch (Exception ex)
|
||||
|
||||
{
|
||||
|
||||
logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath);
|
||||
|
||||
throw;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var yamlStream = new YamlStream();
|
||||
|
||||
using (var reader = new StringReader(yamlText))
|
||||
|
||||
{
|
||||
|
||||
yamlStream.Load(reader);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode)
|
||||
|
||||
{
|
||||
|
||||
throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node.");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var (grants, scopes) = CollectGrantsAndScopes(rootNode);
|
||||
|
||||
|
||||
|
||||
if (!TryGetMapping(rootNode, "info", out var infoNode))
|
||||
|
||||
{
|
||||
|
||||
infoNode = new YamlMappingNode();
|
||||
|
||||
rootNode.Children[new YamlScalarNode("info")] = infoNode;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var serviceName = "authority";
|
||||
|
||||
var buildVersion = ResolveBuildVersion();
|
||||
|
||||
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);
|
||||
|
||||
|
||||
|
||||
var apiVersion = TryGetScalar(infoNode, "version", out var version)
|
||||
|
||||
? version
|
||||
|
||||
: "0.0.0";
|
||||
|
||||
|
||||
|
||||
var updatedYaml = WriteYaml(yamlStream);
|
||||
|
||||
var json = ConvertYamlToJson(updatedYaml);
|
||||
|
||||
var etag = CreateStrongEtag(json);
|
||||
|
||||
|
||||
|
||||
return new OpenApiDocumentSnapshot(
|
||||
|
||||
serviceName,
|
||||
|
||||
apiVersion,
|
||||
|
||||
buildVersion,
|
||||
|
||||
json,
|
||||
|
||||
updatedYaml,
|
||||
|
||||
etag,
|
||||
|
||||
lastWriteUtc,
|
||||
|
||||
grants,
|
||||
|
||||
scopes);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static (IReadOnlyList<string> Grants, IReadOnlyList<string> Scopes) CollectGrantsAndScopes(YamlMappingNode root)
|
||||
|
||||
{
|
||||
|
||||
if (!TryGetMapping(root, "components", out var components) ||
|
||||
|
||||
!TryGetMapping(components, "securitySchemes", out var securitySchemes))
|
||||
|
||||
{
|
||||
|
||||
return (Array.Empty<string>(), Array.Empty<string>());
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var grants = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var scopes = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
foreach (var scheme in securitySchemes.Children.Values.OfType<YamlMappingNode>())
|
||||
|
||||
{
|
||||
|
||||
if (!TryGetMapping(scheme, "flows", out var flows))
|
||||
|
||||
{
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
foreach (var flowEntry in flows.Children)
|
||||
|
||||
{
|
||||
|
||||
if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping)
|
||||
|
||||
{
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
|
||||
internal const string ConfirmationClaimType = "cnf";
|
||||
internal const string SenderConstraintClaimType = "authority_sender_constraint";
|
||||
internal const string SenderNonceClaimType = "authority_sender_nonce";
|
||||
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
|
||||
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
|
||||
internal const string MtlsCertificateHexClaimType = "authority_sender_certificate_hex";
|
||||
internal const string ClientTenantProperty = "authority:client_tenant";
|
||||
internal const string ClientProjectProperty = "authority:client_project";
|
||||
internal const string ClientAttributesProperty = "authority:client_attributes";
|
||||
|
||||
@@ -1177,6 +1177,50 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
|
||||
var extraProperties = new List<AuthEventProperty>();
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var auditSenderConstraintObj) &&
|
||||
auditSenderConstraintObj is string auditSenderConstraint &&
|
||||
!string.IsNullOrWhiteSpace(auditSenderConstraint))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "sender.constraint",
|
||||
Value = ClassifiedString.Public(auditSenderConstraint)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var auditDpopThumbprintObj) &&
|
||||
auditDpopThumbprintObj is string auditDpopThumbprint &&
|
||||
!string.IsNullOrWhiteSpace(auditDpopThumbprint))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "sender.dpop.jkt",
|
||||
Value = ClassifiedString.Sensitive(auditDpopThumbprint)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var auditMtlsThumbprintObj) &&
|
||||
auditMtlsThumbprintObj is string auditMtlsThumbprint &&
|
||||
!string.IsNullOrWhiteSpace(auditMtlsThumbprint))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "sender.mtls.x5t",
|
||||
Value = ClassifiedString.Sensitive(auditMtlsThumbprint)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var auditMtlsHexObj) &&
|
||||
auditMtlsHexObj is string auditMtlsHex &&
|
||||
!string.IsNullOrWhiteSpace(auditMtlsHex))
|
||||
{
|
||||
extraProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "sender.mtls.x5t_hex",
|
||||
Value = ClassifiedString.Sensitive(auditMtlsHex)
|
||||
});
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorReasonProperty, out var operatorReasonObj) &&
|
||||
operatorReasonObj is string operatorReason &&
|
||||
!string.IsNullOrWhiteSpace(operatorReason))
|
||||
@@ -1873,6 +1917,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
record.SenderKeyThumbprint = senderThumbprint;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var senderCertHexObj) &&
|
||||
senderCertHexObj is string senderCertHex &&
|
||||
!string.IsNullOrWhiteSpace(senderCertHex))
|
||||
{
|
||||
record.SenderCertificateHex = senderCertHex;
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantObj) &&
|
||||
tenantObj is string tenantValue &&
|
||||
!string.IsNullOrWhiteSpace(tenantValue))
|
||||
@@ -1976,6 +2027,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
|
||||
nonceObj is string consumedNonce &&
|
||||
!string.IsNullOrWhiteSpace(consumedNonce))
|
||||
{
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.SenderNonceClaimType, consumedNonce);
|
||||
}
|
||||
|
||||
break;
|
||||
case AuthoritySenderConstraintKinds.Mtls:
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
|
||||
@@ -1990,6 +2048,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
|
||||
}
|
||||
|
||||
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateHexProperty, out var mtlsHexObj) &&
|
||||
mtlsHexObj is string mtlsHex &&
|
||||
!string.IsNullOrWhiteSpace(mtlsHex))
|
||||
{
|
||||
identity.SetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, mtlsHex);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,13 @@ using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||
{
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IDpopProofValidator proofValidator;
|
||||
internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||
{
|
||||
private const string AnyDpopKeyThumbprint = "__authority_any_dpop_key__";
|
||||
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IDpopProofValidator proofValidator;
|
||||
private readonly IDpopNonceStore nonceStore;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
@@ -88,15 +90,34 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
return;
|
||||
}
|
||||
|
||||
var senderConstraint = NormalizeSenderConstraint(clientDocument);
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = senderConstraint;
|
||||
|
||||
if (!string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Dpop, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredAudiences = EnsureRequestAudiences(context.Request, clientDocument);
|
||||
var senderConstraint = NormalizeSenderConstraint(clientDocument);
|
||||
var configuredAudiences = EnsureRequestAudiences(context.Request, clientDocument);
|
||||
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
|
||||
|
||||
string? matchedNonceAudience = null;
|
||||
if (senderConstraintOptions.Dpop.Enabled && nonceOptions.Enabled)
|
||||
{
|
||||
matchedNonceAudience = ResolveNonceAudience(context.Request, nonceOptions, configuredAudiences);
|
||||
}
|
||||
|
||||
var requiresClientSenderConstraint = string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Dpop, StringComparison.Ordinal);
|
||||
var requiresConfiguredAudience = senderConstraintOptions.Dpop.Enabled && matchedNonceAudience is not null;
|
||||
|
||||
var effectiveSenderConstraint = requiresClientSenderConstraint || requiresConfiguredAudience
|
||||
? AuthoritySenderConstraintKinds.Dpop
|
||||
: senderConstraint;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = effectiveSenderConstraint;
|
||||
|
||||
if (!requiresClientSenderConstraint && !requiresConfiguredAudience)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresConfiguredAudience && !requiresClientSenderConstraint)
|
||||
{
|
||||
logger.LogDebug("DPoP enforcement enabled for client {ClientId} targeting audience {Audience}.", clientId, matchedNonceAudience);
|
||||
}
|
||||
|
||||
if (!senderConstraintOptions.Dpop.Enabled)
|
||||
{
|
||||
@@ -125,18 +146,18 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
return;
|
||||
}
|
||||
|
||||
if (!httpRequest.Headers.TryGetValue("DPoP", out StringValues proofHeader) || StringValues.IsNullOrEmpty(proofHeader))
|
||||
{
|
||||
logger.LogWarning("Missing DPoP header for client credentials request from {ClientId}.", clientId);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: null,
|
||||
thumbprint: null,
|
||||
reasonCode: "missing_proof",
|
||||
description: "DPoP proof is required.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
if (!httpRequest.Headers.TryGetValue("DPoP", out StringValues proofHeader) || StringValues.IsNullOrEmpty(proofHeader))
|
||||
{
|
||||
logger.LogWarning("Missing DPoP header for client credentials request from {ClientId}.", clientId);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: matchedNonceAudience,
|
||||
thumbprint: null,
|
||||
reasonCode: "missing_proof",
|
||||
description: "DPoP proof is required.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,36 +171,36 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
cancellationToken: context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var error = string.IsNullOrWhiteSpace(validationResult.ErrorDescription)
|
||||
? "DPoP proof validation failed."
|
||||
: validationResult.ErrorDescription;
|
||||
{
|
||||
var error = string.IsNullOrWhiteSpace(validationResult.ErrorDescription)
|
||||
? "DPoP proof validation failed."
|
||||
: validationResult.ErrorDescription;
|
||||
|
||||
logger.LogWarning("DPoP proof validation failed for client {ClientId}: {Reason}.", clientId, error);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: matchedNonceAudience,
|
||||
thumbprint: null,
|
||||
reasonCode: validationResult.ErrorCode ?? "invalid_proof",
|
||||
description: error,
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning("DPoP proof validation failed for client {ClientId}: {Reason}.", clientId, error);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: null,
|
||||
thumbprint: null,
|
||||
reasonCode: validationResult.ErrorCode ?? "invalid_proof",
|
||||
description: error,
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResult.PublicKey is not Microsoft.IdentityModel.Tokens.JsonWebKey jwk)
|
||||
{
|
||||
logger.LogWarning("DPoP proof for {ClientId} did not expose a JSON Web Key.", clientId);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: null,
|
||||
thumbprint: null,
|
||||
reasonCode: "invalid_key",
|
||||
description: "DPoP proof must embed a JSON Web Key.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
if (validationResult.PublicKey is not Microsoft.IdentityModel.Tokens.JsonWebKey jwk)
|
||||
{
|
||||
logger.LogWarning("DPoP proof for {ClientId} did not expose a JSON Web Key.", clientId);
|
||||
await ChallengeNonceAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
audience: matchedNonceAudience,
|
||||
thumbprint: null,
|
||||
reasonCode: "invalid_key",
|
||||
description: "DPoP proof must embed a JSON Web Key.",
|
||||
senderConstraintOptions,
|
||||
httpResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,8 +230,7 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopIssuedAtProperty] = issuedAt;
|
||||
}
|
||||
|
||||
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
|
||||
var requiredAudience = ResolveNonceAudience(context.Request, nonceOptions, configuredAudiences);
|
||||
var requiredAudience = matchedNonceAudience;
|
||||
|
||||
if (nonceOptions.Enabled && requiredAudience is not null)
|
||||
{
|
||||
@@ -232,12 +252,12 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
return;
|
||||
}
|
||||
|
||||
var consumeResult = await nonceStore.TryConsumeAsync(
|
||||
suppliedNonce,
|
||||
requiredAudience,
|
||||
clientDocument.ClientId,
|
||||
thumbprint,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
var consumeResult = await ConsumeNonceAsync(
|
||||
suppliedNonce,
|
||||
requiredAudience,
|
||||
clientDocument,
|
||||
thumbprint,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
switch (consumeResult.Status)
|
||||
{
|
||||
@@ -442,11 +462,11 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
return null;
|
||||
}
|
||||
|
||||
private async ValueTask ChallengeNonceAsync(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityClientDocument clientDocument,
|
||||
string? audience,
|
||||
string? thumbprint,
|
||||
private async ValueTask ChallengeNonceAsync(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityClientDocument clientDocument,
|
||||
string? audience,
|
||||
string? thumbprint,
|
||||
string reasonCode,
|
||||
string description,
|
||||
AuthoritySenderConstraintOptions senderConstraintOptions,
|
||||
@@ -455,20 +475,25 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, description);
|
||||
metadataAccessor.SetTag("authority.dpop_result", reasonCode);
|
||||
|
||||
string? issuedNonce = null;
|
||||
DateTimeOffset? expiresAt = null;
|
||||
if (audience is not null && thumbprint is not null && senderConstraintOptions.Dpop.Nonce.Enabled)
|
||||
{
|
||||
var issuance = await nonceStore.IssueAsync(
|
||||
audience,
|
||||
clientDocument.ClientId,
|
||||
thumbprint,
|
||||
senderConstraintOptions.Dpop.Nonce.Ttl,
|
||||
senderConstraintOptions.Dpop.Nonce.MaxIssuancePerMinute,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (issuance.Status == DpopNonceIssueStatus.Success)
|
||||
{
|
||||
string? issuedNonce = null;
|
||||
DateTimeOffset? expiresAt = null;
|
||||
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
|
||||
if (audience is not null && nonceOptions.Enabled)
|
||||
{
|
||||
var issuanceThumbprint = string.IsNullOrWhiteSpace(thumbprint)
|
||||
? AnyDpopKeyThumbprint
|
||||
: thumbprint;
|
||||
|
||||
var issuance = await nonceStore.IssueAsync(
|
||||
audience,
|
||||
clientDocument.ClientId,
|
||||
issuanceThumbprint,
|
||||
nonceOptions.Ttl,
|
||||
nonceOptions.MaxIssuancePerMinute,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (issuance.Status == DpopNonceIssueStatus.Success)
|
||||
{
|
||||
issuedNonce = issuance.Nonce;
|
||||
expiresAt = issuance.ExpiresAt;
|
||||
}
|
||||
@@ -488,20 +513,48 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAuditAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
AuthEventOutcome.Failure,
|
||||
description,
|
||||
thumbprint,
|
||||
validationResult: null,
|
||||
audience,
|
||||
"authority.dpop.proof.challenge",
|
||||
reasonCode,
|
||||
issuedNonce,
|
||||
expiresAt)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
await WriteAuditAsync(
|
||||
context,
|
||||
clientDocument,
|
||||
AuthEventOutcome.Failure,
|
||||
description,
|
||||
thumbprint,
|
||||
validationResult: null,
|
||||
audience,
|
||||
"authority.dpop.proof.challenge",
|
||||
reasonCode,
|
||||
issuedNonce,
|
||||
expiresAt)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<DpopNonceConsumeResult> ConsumeNonceAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
AuthorityClientDocument clientDocument,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await nonceStore.TryConsumeAsync(
|
||||
nonce,
|
||||
audience,
|
||||
clientDocument.ClientId,
|
||||
keyThumbprint,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Status == DpopNonceConsumeStatus.NotFound &&
|
||||
!string.Equals(keyThumbprint, AnyDpopKeyThumbprint, StringComparison.Ordinal))
|
||||
{
|
||||
result = await nonceStore.TryConsumeAsync(
|
||||
nonce,
|
||||
audience,
|
||||
clientDocument.ClientId,
|
||||
AnyDpopKeyThumbprint,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string BuildAuthenticateHeader(string reasonCode, string description, string? nonce)
|
||||
{
|
||||
|
||||
@@ -117,6 +117,18 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
document.SenderConstraint = senderConstraint;
|
||||
}
|
||||
|
||||
var senderNonce = principal.GetClaim(AuthorityOpenIddictConstants.SenderNonceClaimType);
|
||||
if (!string.IsNullOrWhiteSpace(senderNonce))
|
||||
{
|
||||
document.SenderNonce = senderNonce;
|
||||
}
|
||||
|
||||
var senderCertificateHex = principal.GetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType);
|
||||
if (!string.IsNullOrWhiteSpace(senderCertificateHex))
|
||||
{
|
||||
document.SenderCertificateHex = senderCertificateHex;
|
||||
}
|
||||
|
||||
var serviceAccountId = principal.GetClaim(StellaOpsClaimTypes.ServiceAccount);
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ using StellaOps.Authority.Notifications.Ack;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Console;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
@@ -115,6 +116,8 @@ builder.Host.UseSerilog((context, _, loggerConfiguration) =>
|
||||
});
|
||||
|
||||
var authorityOptions = authorityConfiguration.Options;
|
||||
builder.Services.AddStellaOpsCrypto(authorityOptions.Crypto);
|
||||
builder.Services.AddHostedService<AuthoritySecretHasherInitializer>();
|
||||
var issuerUri = authorityOptions.Issuer;
|
||||
if (issuerUri is null)
|
||||
{
|
||||
@@ -138,6 +141,7 @@ builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, Defa
|
||||
builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>();
|
||||
builder.Services.TryAddSingleton<IAuthorityAirgapAuditService, AuthorityAirgapAuditService>();
|
||||
builder.Services.AddSingleton<AuthorityOpenApiDocumentProvider>();
|
||||
builder.Services.TryAddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
|
||||
|
||||
#if STELLAOPS_AUTH_SECURITY
|
||||
var senderConstraints = authorityOptions.Security.SenderConstraints;
|
||||
@@ -210,7 +214,6 @@ if (requiresKms)
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, KmsAuthoritySigningKeySource>());
|
||||
}
|
||||
|
||||
builder.Services.AddStellaOpsCrypto();
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
|
||||
builder.Services.AddSingleton<AuthoritySigningKeyManager>();
|
||||
builder.Services.AddSingleton<AuthorityAckTokenKeyManager>();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
internal sealed class AuthoritySecretHasherInitializer : IHostedService
|
||||
{
|
||||
private readonly ICryptoHash hash;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptions;
|
||||
private readonly ILogger<AuthoritySecretHasherInitializer> logger;
|
||||
|
||||
public AuthoritySecretHasherInitializer(
|
||||
ICryptoHash hash,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
ILogger<AuthoritySecretHasherInitializer> logger)
|
||||
{
|
||||
this.hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = authorityOptions.Value;
|
||||
var algorithm = options?.Crypto?.DefaultHashAlgorithm;
|
||||
AuthoritySecretHasher.Configure(hash, algorithm);
|
||||
logger.LogInformation("Authority secret hasher configured with default algorithm {Algorithm}.",
|
||||
string.IsNullOrWhiteSpace(algorithm) ? HashAlgorithms.Sha256 : algorithm);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,181 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthorityJwksService
|
||||
{
|
||||
private const string CacheKey = "authority:jwks:current";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry registry;
|
||||
private readonly ILogger<AuthorityJwksService> logger;
|
||||
private readonly IMemoryCache cache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
|
||||
public AuthorityJwksService(
|
||||
ICryptoProviderRegistry registry,
|
||||
ILogger<AuthorityJwksService> logger,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
public AuthorityJwksResult Get()
|
||||
{
|
||||
if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) &&
|
||||
cached is not null &&
|
||||
cached.ExpiresAt > timeProvider.GetUtcNow())
|
||||
{
|
||||
return cached.Result;
|
||||
}
|
||||
|
||||
var response = new AuthorityJwksResponse(BuildKeys());
|
||||
var signingOptions = authorityOptions.Signing;
|
||||
var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero
|
||||
? signingOptions.JwksCacheLifetime
|
||||
: TimeSpan.FromMinutes(5);
|
||||
var expires = timeProvider.GetUtcNow().Add(lifetime);
|
||||
var etag = ComputeEtag(response, expires);
|
||||
var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}";
|
||||
|
||||
var result = new AuthorityJwksResult(response, etag, expires, cacheControl);
|
||||
var entry = new AuthorityJwksCacheEntry(result, expires);
|
||||
|
||||
cache.Set(CacheKey, entry, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = lifetime
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
cache.Remove(CacheKey);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<JwksKeyEntry> BuildKeys()
|
||||
{
|
||||
var keys = new List<JwksKeyEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in registry.Providers)
|
||||
{
|
||||
foreach (var signingKey in provider.GetSigningKeys())
|
||||
{
|
||||
var keyId = signingKey.Reference.KeyId;
|
||||
if (!seen.Add(keyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference);
|
||||
var jwk = signer.ExportPublicJsonWebKey();
|
||||
var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse)
|
||||
? metadataUse
|
||||
: jwk.Use;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyUse))
|
||||
{
|
||||
keyUse = "sig";
|
||||
}
|
||||
|
||||
var entry = new JwksKeyEntry
|
||||
{
|
||||
Kid = jwk.Kid,
|
||||
Kty = jwk.Kty,
|
||||
Use = keyUse,
|
||||
Alg = jwk.Alg,
|
||||
Crv = jwk.Crv,
|
||||
X = jwk.X,
|
||||
Y = jwk.Y,
|
||||
Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active"
|
||||
};
|
||||
keys.Add(entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal));
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(response, SerializerOptions);
|
||||
var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
var hash = SHA256.HashData(buffer);
|
||||
return $"\"{Convert.ToHexString(hash)}\"";
|
||||
}
|
||||
|
||||
private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection<JwksKeyEntry> Keys);
|
||||
|
||||
internal sealed record AuthorityJwksResult(
|
||||
AuthorityJwksResponse Response,
|
||||
string ETag,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string CacheControl);
|
||||
|
||||
internal sealed class JwksKeyEntry
|
||||
{
|
||||
[JsonPropertyName("kty")]
|
||||
public string? Kty { get; set; }
|
||||
|
||||
[JsonPropertyName("use")]
|
||||
public string? Use { get; set; }
|
||||
|
||||
[JsonPropertyName("kid")]
|
||||
public string? Kid { get; set; }
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Alg { get; set; }
|
||||
|
||||
[JsonPropertyName("crv")]
|
||||
public string? Crv { get; set; }
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public string? X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public string? Y { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthorityJwksService
|
||||
{
|
||||
private const string CacheKey = "authority:jwks:current";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry registry;
|
||||
private readonly ICryptoHash hash;
|
||||
private readonly ILogger<AuthorityJwksService> logger;
|
||||
private readonly IMemoryCache cache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
|
||||
public AuthorityJwksService(
|
||||
ICryptoProviderRegistry registry,
|
||||
ICryptoHash hash,
|
||||
ILogger<AuthorityJwksService> logger,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
public AuthorityJwksResult Get()
|
||||
{
|
||||
if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) &&
|
||||
cached is not null &&
|
||||
cached.ExpiresAt > timeProvider.GetUtcNow())
|
||||
{
|
||||
return cached.Result;
|
||||
}
|
||||
|
||||
var response = new AuthorityJwksResponse(BuildKeys());
|
||||
var signingOptions = authorityOptions.Signing;
|
||||
var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero
|
||||
? signingOptions.JwksCacheLifetime
|
||||
: TimeSpan.FromMinutes(5);
|
||||
var expires = timeProvider.GetUtcNow().Add(lifetime);
|
||||
var etag = ComputeEtag(response, expires);
|
||||
var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}";
|
||||
|
||||
var result = new AuthorityJwksResult(response, etag, expires, cacheControl);
|
||||
var entry = new AuthorityJwksCacheEntry(result, expires);
|
||||
|
||||
cache.Set(CacheKey, entry, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = lifetime
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
cache.Remove(CacheKey);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<JwksKeyEntry> BuildKeys()
|
||||
{
|
||||
var keys = new List<JwksKeyEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in registry.Providers)
|
||||
{
|
||||
foreach (var signingKey in provider.GetSigningKeys())
|
||||
{
|
||||
var keyId = signingKey.Reference.KeyId;
|
||||
if (!seen.Add(keyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference);
|
||||
var jwk = signer.ExportPublicJsonWebKey();
|
||||
var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse)
|
||||
? metadataUse
|
||||
: jwk.Use;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyUse))
|
||||
{
|
||||
keyUse = "sig";
|
||||
}
|
||||
|
||||
var entry = new JwksKeyEntry
|
||||
{
|
||||
Kid = jwk.Kid,
|
||||
Kty = jwk.Kty,
|
||||
Use = keyUse,
|
||||
Alg = jwk.Alg,
|
||||
Crv = jwk.Crv,
|
||||
X = jwk.X,
|
||||
Y = jwk.Y,
|
||||
Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active"
|
||||
};
|
||||
keys.Add(entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal));
|
||||
return keys;
|
||||
}
|
||||
|
||||
private string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(response, SerializerOptions);
|
||||
var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
var digest = hash.ComputeHash(buffer, HashAlgorithms.Sha256);
|
||||
return $"\"{Convert.ToHexString(digest)}\"";
|
||||
}
|
||||
|
||||
private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection<JwksKeyEntry> Keys);
|
||||
|
||||
internal sealed record AuthorityJwksResult(
|
||||
AuthorityJwksResponse Response,
|
||||
string ETag,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string CacheControl);
|
||||
|
||||
internal sealed class JwksKeyEntry
|
||||
{
|
||||
[JsonPropertyName("kty")]
|
||||
public string? Kty { get; set; }
|
||||
|
||||
[JsonPropertyName("use")]
|
||||
public string? Use { get; set; }
|
||||
|
||||
[JsonPropertyName("kid")]
|
||||
public string? Kid { get; set; }
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Alg { get; set; }
|
||||
|
||||
[JsonPropertyName("crv")]
|
||||
public string? Crv { get; set; }
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public string? X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public string? Y { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,170 +1,182 @@
|
||||
# Authority Host Task Board — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
|
||||
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
|
||||
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
|
||||
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
|
||||
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
|
||||
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass).
|
||||
> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
|
||||
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
|
||||
| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
|
||||
> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour.
|
||||
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
|
||||
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
|
||||
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
|
||||
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
|
||||
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
|
||||
| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
|
||||
> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`.
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
|
||||
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
|
||||
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
|
||||
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
|
||||
> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
|
||||
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
|
||||
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
|
||||
| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement.
|
||||
|
||||
## Exceptions v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
|
||||
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
|
||||
> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours.
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
|
||||
| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
|
||||
|
||||
## Export Center
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Notifications Studio
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
|
||||
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
|
||||
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
|
||||
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
|
||||
|
||||
|
||||
## CLI Parity & Task Packs
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows.
|
||||
> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001).
|
||||
> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`.
|
||||
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
> Blocked: Task Runner approval APIs (`ORCH-SVC-42-101`, `TASKRUN-42-001`) still outstanding. Pack scope catalog (AUTH-PACKS-41-001) landed 2025-11-04; resume once execution/approval contracts are published.
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
|
||||
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
|
||||
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
|
||||
> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios.
|
||||
> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
|
||||
| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
|
||||
| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
|
||||
| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
|
||||
> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`.
|
||||
> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests).
|
||||
| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. |
|
||||
> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild).
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
|
||||
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
|
||||
| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
|
||||
> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour.
|
||||
| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
|
||||
> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild).
|
||||
# Authority Host Task Board — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
|
||||
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
|
||||
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
|
||||
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
|
||||
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
|
||||
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
|
||||
|
||||
| AUTH-CRYPTO-90-001 | DOING (2025-11-08) | Authority Core & Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Migrate signing/key-loading paths (`KmsAuthoritySigningKeySource`, `FileAuthoritySigningKeySource`, `AuthorityJwksService`, secret hashers) to `ICryptoProviderRegistry` so regional bundles can pick `ru.cryptopro.csp` / `ru.pkcs11` providers as defined in `docs/security/crypto-routing-audit-2025-11-07.md`. | All signing + hashing code paths resolve registry providers; Authority config exposes provider selection; JWKS output references sovereign keys; regression tests updated. |
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass).
|
||||
> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-DPOP-11-001 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. |
|
||||
> 2025-11-07: Joint Authority/DevOps stand-up committed to shipping nonce store + telemetry updates by 2025-11-10; config samples and integration tests being updated in tandem.
|
||||
| AUTH-MTLS-11-002 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. |
|
||||
> 2025-11-08: Wiring cert thumbprint persistence + audit hooks now that DPoP nonce enforcement is in place; targeting shared delivery window with DEVOPS-AIRGAP-57-002.
|
||||
> 2025-11-07: Same stand-up aligned on 2025-11-10 target for mTLS enforcement + JWKS rotation docs so plugin mitigations can unblock.
|
||||
| AUTH-POLICY-23-001 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-002 | Introduce fine-grained policy scopes (`policy:read`, `policy:author`, `policy:review`, `policy:simulate`, `findings:read`) for CLI/service identities; refresh discovery metadata, issuer templates, and offline defaults. | Scope catalogue and sample configs updated; `policy-cli` seed credentials rotated; docs recorded migration steps. |
|
||||
| AUTH-POLICY-23-002 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
|
||||
> 2025-11-08: Policy Engine enforces pending_second_approval when dual-control toggles demand it, activation auditor emits structured `policy.activation.*` scopes, and tests cover settings/audits.
|
||||
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
|
||||
| AUTH-POLICY-23-003 | DONE (2025-11-08) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
|
||||
> 2025-11-08: Docs refreshed for dual-control activation (console workflow, compliance checklist, sample YAML) and linked to new Policy Engine activation options.
|
||||
> 2025-11-07: Scope migration landed (AUTH-POLICY-23-001); dual-approval + documentation tasks now waiting on pairing.
|
||||
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
|
||||
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
|
||||
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
|
||||
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
|
||||
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
|
||||
| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
|
||||
> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`.
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
|
||||
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
|
||||
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
|
||||
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
|
||||
> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
|
||||
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
|
||||
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
|
||||
| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement.
|
||||
|
||||
## Exceptions v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
|
||||
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
|
||||
> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours.
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
|
||||
| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
|
||||
|
||||
## Export Center
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Notifications Studio
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
|
||||
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
|
||||
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
|
||||
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
|
||||
|
||||
|
||||
## CLI Parity & Task Packs
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows.
|
||||
> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001).
|
||||
> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`.
|
||||
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
> Blocked: ORCH-SVC-42-101 (Orchestrator log streaming/approvals API) still TODO. AUTH-PACKS-41-001 + TASKRUN-42-001 are DONE (2025-11-04); resume once Orchestrator publishes contracts.
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
|
||||
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
|
||||
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
|
||||
> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios.
|
||||
> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
|
||||
| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
|
||||
| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
|
||||
| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
|
||||
> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`.
|
||||
> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests).
|
||||
| AUTH-AIRGAP-57-001 | DOING (2025-11-08) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Implement Authority-side sealed-mode checks once DevOps publishes sealed CI artefacts + contract (target 2025-11-10). |
|
||||
> 2025-11-08: Picked up in tandem with DEVOPS-AIRGAP-57-002 — validating sealed confirmation payload + wiring Authority gating tests against ops/devops/sealed-mode-ci artefacts.
|
||||
> 2025-11-08: `/token`/`/introspect` now reject mTLS-bound tokens without the recorded certificate; `authority_mtls_mismatch_total` metric + docs updated for plugin consumers.
|
||||
> 2025-11-08: DevOps sealed-mode CI now emits `authority-sealed-ci.json`; ingest that contract next to unblock enforcement switch.
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
|
||||
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
|
||||
| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
|
||||
> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour.
|
||||
| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
|
||||
> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild).
|
||||
|
||||
Reference in New Issue
Block a user