From d71c81e45d5a129e572dd3e481e7ce43827f0f3d Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 8 Nov 2025 23:28:41 +0200 Subject: [PATCH] feat: Update Sprint 110 documentation and enhance Advisory AI tests for determinism and mTLS validation --- .../implplan/SPRINT_110_ingestion_evidence.md | 2 +- docs/modules/advisory-ai/architecture.md | 20 ++- src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md | 3 +- .../AdvisoryPipelineExecutorTests.cs | 106 +++++++++++++ .../AdvisoryPlanCacheTests.cs | 26 +++- .../ClientCredentialsAndTokenHandlersTests.cs | 147 ++++++++++++++++++ .../Handlers/RefreshTokenHandlers.cs | 97 +++++++++++- .../StellaOps.Concelier.WebService/Program.cs | 6 +- .../WebServiceEndpointsTests.cs | 7 + 9 files changed, 395 insertions(+), 19 deletions(-) diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md index ba85eb696..2704c89b0 100644 --- a/docs/implplan/SPRINT_110_ingestion_evidence.md +++ b/docs/implplan/SPRINT_110_ingestion_evidence.md @@ -8,7 +8,7 @@ Active items only. Completed/historic work now resides in docs/implplan/archived - 2025-11-04: AIAI-31-002 and AIAI-31-003 shipped with deterministic SBOM context client wiring (`AddSbomContext` typed HTTP client) and toolset integration; WebService/Worker now invoke the orchestrator with SBOM-backed simulations and emit initial metrics. - 2025-11-03: AIAI-31-002 landed the configurable HTTP client + DI defaults; retriever now resolves data via `/v1/sbom/context`, retaining a null fallback until SBOM service ships. - 2025-11-03: Follow-up: SBOM guild to deliver base URL/API key and run an Advisory AI smoke retrieval once SBOM-AIAI-31-001 endpoints are live. - - 2025-11-08: AIAI-31-009 moved to DOING – building the QA harness (injection fixtures, golden/property/perf tests) plus documenting deterministic cache guarantees before release. + - 2025-11-08: AIAI-31-009 marked DONE – injection harness + dual golden prompts + plan-cache determinism tests landed; perf memo added to Advisory AI architecture, `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-build` green. - **Concelier** – CONCELIER-CORE-AOC-19-004 is the only in-flight Concelier item; air-gap, console, attestation, and Link-Not-Merge tasks remain TODO, and several connector upgrades still carry overdue October due dates. - **Excititor** – Excititor WebService, console, policy, and observability tracks are all TODO and hinge on Link-Not-Merge schema delivery plus trust-provenance connectors (SUSE/Ubuntu) progressing in section 110.C. - **Mirror** – Mirror Creator track (MIRROR-CRT-56-001 through MIRROR-CRT-58-002) has not started; DSSE signing, OCI bundle, and scheduling integrations depend on the deterministic bundle assembler landing first. diff --git a/docs/modules/advisory-ai/architecture.md b/docs/modules/advisory-ai/architecture.md index 529c3feb9..3be566ba4 100644 --- a/docs/modules/advisory-ai/architecture.md +++ b/docs/modules/advisory-ai/architecture.md @@ -131,9 +131,17 @@ All endpoints accept `profile` parameter (default `fips-local`) and return `outp - Rate limits (per tenant, per profile) enforced by Orchestrator to prevent runaway usage. - Offline/air-gapped deployments run local models packaged with Offline Kit; model weights validated via manifest digests. -## 11) Hosting surfaces - -- **WebService** — exposes `/v1/advisory-ai/pipeline/{task}` to materialise plans and enqueue execution messages. -- **Worker** — background service draining the advisory pipeline queue (file-backed stub) pending integration with shared transport. -- Both hosts register `AddAdvisoryAiCore`, which wires the SBOM context client, deterministic toolset, pipeline orchestrator, and queue metrics. -- SBOM base address + tenant metadata are configured via `AdvisoryAI:SbomBaseAddress` and propagated through `AddSbomContext`. +## 11) Hosting surfaces + +- **WebService** — exposes `/v1/advisory-ai/pipeline/{task}` to materialise plans and enqueue execution messages. +- **Worker** — background service draining the advisory pipeline queue (file-backed stub) pending integration with shared transport. +- Both hosts register `AddAdvisoryAiCore`, which wires the SBOM context client, deterministic toolset, pipeline orchestrator, and queue metrics. +- SBOM base address + tenant metadata are configured via `AdvisoryAI:SbomBaseAddress` and propagated through `AddSbomContext`. + +## 12) QA harness & determinism (Sprint 110 refresh) + +- **Injection fixtures:** `src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TestData/prompt-injection-fixtures.txt` drives `AdvisoryGuardrailInjectionTests`, ensuring blocked phrases (`ignore previous instructions`, `override the system prompt`, etc.) are rejected with redaction counters, preventing prompt-injection regressions. +- **Golden prompts:** `summary-prompt.json` now pairs with `conflict-prompt.json`; `AdvisoryPromptAssemblerTests` load both to enforce deterministic JSON payloads across task types and verify vector preview truncation (600 characters + ellipsis) keeps prompts under the documented perf ceiling. +- **Plan determinism:** `AdvisoryPipelineOrchestratorTests` shuffle structured/vector/SBOM inputs and assert cache keys + metadata remain stable, proving that seeded plan caches stay deterministic even when retrievers emit out-of-order results. +- **Execution telemetry:** `AdvisoryPipelineExecutorTests` exercise partial citation coverage (target ≥0.5 when only half the structured chunks are cited) so `advisory_ai_citation_coverage_ratio` reflects real guardrail quality. +- **Plan cache stability:** `AdvisoryPlanCacheTests` now seed the in-memory cache with a fake time provider to confirm TTL refresh when plans are replaced, guaranteeing reproducible eviction under air-gapped runs. diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md index b1ab5224b..a4011c211 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md @@ -14,7 +14,7 @@ | AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. | | AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. | | AIAI-31-011 | DONE (2025-11-02) | Advisory AI Guild | EXCITITOR-LNM-21-201, EXCITITOR-CORE-AOC-19-002 | Implement Excititor VEX document provider to surface structured VEX statements for vector retrieval. | Provider returns conflict-aware VEX chunks with deterministic metadata and tests for representative statements. | -| AIAI-31-009 | DOING (2025-11-08) | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. | +| AIAI-31-009 | DONE (2025-11-08) | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. | > 2025-11-02: AIAI-31-002 – SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild. > 2025-11-04: AIAI-31-002 – Introduced `SbomContextHttpClient`, DI helper (`AddSbomContext`), and HTTP-mapping tests; retriever wired to typed client with tenant header support and deterministic query construction. @@ -33,3 +33,4 @@ > 2025-11-04: AIAI-31-006 DONE – REST endpoints enforce header scopes, apply token bucket rate limiting, sanitize prompts via guardrails, and queue execution with cached metadata. Tests executed via `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-restore`. > 2025-11-06: AIAI-31-004B/C – Resuming prompt/cache hardening and CLI integration; first focus on backend client wiring and deterministic CLI outputs before full suite. > 2025-11-06: AIAI-31-004B/C DONE – Advisory AI Mongo integration validated, backend client + CLI `advise run` wired, deterministic console renderer with provenance/guardrail display added, docs refreshed, and targeted CLI tests executed. +> 2025-11-08: AIAI-31-009 DONE – Added prompt-injection harness, dual golden prompts (summary/conflict), cache determinism/property tests, partial citation telemetry coverage, and plan-cache expiry refresh validation; `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-build` passes. diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs index 1429e747e..54fc83423 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPipelineExecutorTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.Metrics; +using System.Linq; using FluentAssertions; using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Documents; @@ -118,6 +119,44 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable Math.Abs(measurement.Value - 1d) < 0.0001); } + [Fact] + public async Task ExecuteAsync_ComputesPartialCitationCoverage() + { + using var listener = new MeterListener(); + var doubleMeasurements = new List<(string Name, double Value, IEnumerable> Tags)>(); + + listener.InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == AdvisoryPipelineMetrics.MeterName) + { + l.EnableMeasurementEvents(instrument); + } + }; + + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + { + doubleMeasurements.Add((instrument.Name, measurement, tags)); + }); + + listener.Start(); + + var plan = BuildPlanWithTwoChunks(cacheKey: "CACHE-4"); + var assembler = new PartialCitationPromptAssembler(plan, citationsToKeep: 1); + var guardrail = new StubGuardrailPipeline(blocked: false); + var store = new InMemoryAdvisoryOutputStore(); + using var metrics = new AdvisoryPipelineMetrics(_meterFactory); + var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System); + + var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request); + await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None); + + listener.Dispose(); + + doubleMeasurements.Should().Contain(measurement => + measurement.Name == "advisory_ai_citation_coverage_ratio" && + Math.Abs(measurement.Value - 0.5d) < 0.0001); + } + private static AdvisoryTaskPlan BuildMinimalPlan(string cacheKey) { var request = new AdvisoryTaskRequest( @@ -148,6 +187,46 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable return plan; } + private static AdvisoryTaskPlan BuildPlanWithTwoChunks(string cacheKey) + { + var request = new AdvisoryTaskRequest( + AdvisoryTaskType.Summary, + advisoryKey: "adv-key", + artifactId: "artifact-1", + profile: "default"); + + var chunkOne = AdvisoryChunk.Create( + "doc-1", + "chunk-1", + "Summary", + "para-1", + "Summary details", + new Dictionary { ["section"] = "Summary" }); + + var chunkTwo = AdvisoryChunk.Create( + "doc-1", + "chunk-2", + "Impact", + "para-2", + "Impact details", + new Dictionary { ["section"] = "Impact" }); + + var structured = ImmutableArray.Create(chunkOne, chunkTwo); + + var plan = new AdvisoryTaskPlan( + request, + cacheKey, + promptTemplate: "prompts/advisory/summary.liquid", + structuredChunks: structured, + vectorResults: ImmutableArray.Empty, + sbomContext: null, + dependencyAnalysis: DependencyAnalysisResult.Empty("artifact-1"), + budget: new AdvisoryTaskBudget { PromptTokens = 512, CompletionTokens = 256 }, + metadata: ImmutableDictionary.Empty.Add("advisory_key", "adv-key")); + + return plan; + } + private sealed class StubPromptAssembler : IAdvisoryPromptAssembler { public Task AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken) @@ -166,6 +245,33 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable } } + private sealed class PartialCitationPromptAssembler : IAdvisoryPromptAssembler + { + private readonly ImmutableArray _citations; + + public PartialCitationPromptAssembler(AdvisoryTaskPlan plan, int citationsToKeep) + { + _citations = plan.StructuredChunks + .Take(Math.Clamp(citationsToKeep, 0, plan.StructuredChunks.Length)) + .Select((chunk, index) => new AdvisoryPromptCitation(index + 1, chunk.DocumentId, chunk.ChunkId)) + .ToImmutableArray(); + } + + public Task AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken) + { + var metadata = ImmutableDictionary.Empty.Add("advisory_key", plan.Request.AdvisoryKey); + var diagnostics = ImmutableDictionary.Empty.Add("structured_chunks", plan.StructuredChunks.Length.ToString()); + return Task.FromResult(new AdvisoryPrompt( + plan.CacheKey, + plan.Request.TaskType, + plan.Request.Profile, + "{\"prompt\":\"value\"}", + _citations, + metadata, + diagnostics)); + } + } + private sealed class StubGuardrailPipeline : IAdvisoryGuardrailPipeline { private readonly AdvisoryGuardrailResult _result; diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPlanCacheTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPlanCacheTests.cs index ec645a228..3acd1f7e2 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPlanCacheTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/AdvisoryPlanCacheTests.cs @@ -48,6 +48,26 @@ public sealed class AdvisoryPlanCacheTests retrieved.Should().BeNull(); } + [Fact] + public async Task SetAsync_ReplacesPlanAndRefreshesExpiration() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cache = CreateCache(timeProvider, ttl: TimeSpan.FromMinutes(1)); + var original = CreatePlan(cacheKey: "stable-cache", advisoryKey: "ADV-123"); + await cache.SetAsync(original.CacheKey, original, CancellationToken.None); + + timeProvider.Advance(TimeSpan.FromSeconds(45)); + + var replacement = CreatePlan(cacheKey: "stable-cache", advisoryKey: "ADV-999"); + await cache.SetAsync(replacement.CacheKey, replacement, CancellationToken.None); + + timeProvider.Advance(TimeSpan.FromSeconds(50)); // total 95s since first insert, 50s since replacement + + var retrieved = await cache.TryGetAsync(replacement.CacheKey, CancellationToken.None); + retrieved.Should().NotBeNull(); + retrieved!.Request.AdvisoryKey.Should().Be("ADV-999"); + } + private static InMemoryAdvisoryPlanCache CreateCache(FakeTimeProvider timeProvider, TimeSpan? ttl = null) { var options = Options.Create(new AdvisoryPlanCacheOptions @@ -59,9 +79,9 @@ public sealed class AdvisoryPlanCacheTests return new InMemoryAdvisoryPlanCache(options, timeProvider); } - private static AdvisoryTaskPlan CreatePlan() + private static AdvisoryTaskPlan CreatePlan(string cacheKey = "plan-cache-key", string advisoryKey = "ADV-123") { - var request = new AdvisoryTaskRequest(AdvisoryTaskType.Summary, "ADV-123", artifactId: "artifact-1"); + var request = new AdvisoryTaskRequest(AdvisoryTaskType.Summary, advisoryKey, artifactId: "artifact-1"); var chunk = AdvisoryChunk.Create("doc-1", "chunk-1", "section", "para", "text"); var structured = ImmutableArray.Create(chunk); var vectors = ImmutableArray.Create(new AdvisoryVectorResult("query", ImmutableArray.Empty)); @@ -72,7 +92,7 @@ public sealed class AdvisoryPlanCacheTests new KeyValuePair("task_type", request.TaskType.ToString()) }); - return new AdvisoryTaskPlan(request, "plan-cache-key", "template", structured, vectors, sbom, dependency, new AdvisoryTaskBudget(), metadata); + return new AdvisoryTaskPlan(request, cacheKey, "template", structured, vectors, sbom, dependency, new AdvisoryTaskBudget(), metadata); } private sealed class FakeTimeProvider : TimeProvider diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs index 97497e974..848330df2 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs @@ -4352,6 +4352,24 @@ internal sealed class RecordingCertificateValidator : IAuthorityClientCertificat } } +internal sealed class StubCertificateValidator : IAuthorityClientCertificateValidator +{ + private readonly Func factory; + + public StubCertificateValidator(Func factory) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public bool Invoked { get; private set; } + + public ValueTask ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken) + { + Invoked = true; + return ValueTask.FromResult(factory()); + } +} + internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor { public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) @@ -4865,6 +4883,118 @@ public class ObservabilityIncidentTokenHandlerTests Assert.False(context.IsRejected); } + + [Fact] + public async Task ValidateRefreshTokenGrantHandler_RejectsMtlsRefresh_WhenCertificateMissing() + { + var clientDocument = CreateClient( + clientId: "mtls-refresh", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials refresh_token"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding { Thumbprint = "ABCDEF" }); + + var clientStore = new TestClientStore(clientDocument); + var validator = new StubCertificateValidator(() => AuthorityClientCertificateValidationResult.Success( + "stub", + "ABCDEF", + clientDocument.CertificateBindings[0])); + + var handler = new ValidateRefreshTokenGrantHandler( + clientStore, + validator, + NullLogger.Instance); + + var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) + { + Principal = CreateMtlsRefreshPrincipal(clientDocument.ClientId, "C0FFEE") + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Sender certificate is required for this token.", context.ErrorDescription); + Assert.False(validator.Invoked); + } + + [Fact] + public async Task ValidateRefreshTokenGrantHandler_RejectsMtlsRefresh_WhenThumbprintMismatch() + { + var clientDocument = CreateClient( + clientId: "mtls-refresh-mismatch", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials refresh_token"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding { Thumbprint = "ABCDEF" }); + + var clientStore = new TestClientStore(clientDocument); + var validator = new StubCertificateValidator(() => AuthorityClientCertificateValidationResult.Success( + Base64UrlEncoder.Encode(Convert.FromHexString("ABCDEF")), + "ABCDEF", + clientDocument.CertificateBindings[0])); + + var handler = new ValidateRefreshTokenGrantHandler( + clientStore, + validator, + NullLogger.Instance); + + var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token"); + var httpContext = new DefaultHttpContext(); + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) + { + Principal = CreateMtlsRefreshPrincipal(clientDocument.ClientId, "DEADBEEF") + }; + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("Sender certificate mismatch.", context.ErrorDescription); + Assert.True(validator.Invoked); + } + + [Fact] + public async Task ValidateRefreshTokenGrantHandler_SetsTransactionState_WhenMtlsRefreshSucceeds() + { + const string HexThumbprint = "CAFEBABE"; + var binding = new AuthorityClientCertificateBinding { Thumbprint = HexThumbprint }; + + var clientDocument = CreateClient( + clientId: "mtls-refresh-success", + secret: "s3cr3t!", + allowedGrantTypes: "client_credentials refresh_token"); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls; + clientDocument.CertificateBindings.Add(binding); + + var clientStore = new TestClientStore(clientDocument); + var validator = new StubCertificateValidator(() => AuthorityClientCertificateValidationResult.Success( + Base64UrlEncoder.Encode(Convert.FromHexString(HexThumbprint)), + HexThumbprint, + binding)); + + var handler = new ValidateRefreshTokenGrantHandler( + clientStore, + validator, + NullLogger.Instance); + + var transaction = TestHelpers.CreateRefreshTransaction(clientDocument.ClientId, "s3cr3t!", "refresh-token"); + var httpContext = new DefaultHttpContext(); + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction) + { + Principal = CreateMtlsRefreshPrincipal(clientDocument.ClientId, HexThumbprint) + }; + + await handler.HandleAsync(context); + + Assert.False(context.IsRejected); + Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]); + Assert.Equal(HexThumbprint, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty]); + Assert.NotNull(context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]); + } } internal static class TestInstruments @@ -5065,6 +5195,23 @@ internal static class TestHelpers }; } + public static ClaimsPrincipal CreateMtlsRefreshPrincipal(string clientId, string hexThumbprint) + { + var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId)); + identity.AddClaim(new Claim(AuthorityOpenIddictConstants.SenderConstraintClaimType, AuthoritySenderConstraintKinds.Mtls)); + identity.AddClaim(new Claim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType, hexThumbprint)); + + var normalizedHex = hexThumbprint.Length % 2 == 0 ? hexThumbprint : "0" + hexThumbprint; + var confirmation = JsonSerializer.Serialize(new Dictionary + { + ["x5t#S256"] = Base64UrlEncoder.Encode(Convert.FromHexString(normalizedHex)) + }); + identity.AddClaim(new Claim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation)); + + return new ClaimsPrincipal(identity); + } + public static string ConvertThumbprintToString(object thumbprint) => thumbprint switch { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/RefreshTokenHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/RefreshTokenHandlers.cs index 2aa0b6941..d909c7254 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/RefreshTokenHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/RefreshTokenHandlers.cs @@ -1,30 +1,41 @@ using System; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using OpenIddict.Abstractions; using OpenIddict.Extensions; using OpenIddict.Server; using StellaOps.Auth.Abstractions; +using StellaOps.Authority.Security; +using StellaOps.Authority.Storage.Mongo.Stores; namespace StellaOps.Authority.OpenIddict.Handlers; internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandler { + private readonly IAuthorityClientStore clientStore; + private readonly IAuthorityClientCertificateValidator certificateValidator; private readonly ILogger logger; - public ValidateRefreshTokenGrantHandler(ILogger logger) + public ValidateRefreshTokenGrantHandler( + IAuthorityClientStore clientStore, + IAuthorityClientCertificateValidator certificateValidator, + ILogger logger) { + this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); + this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) + public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) { ArgumentNullException.ThrowIfNull(context); if (!context.Request.IsRefreshTokenGrantType()) { - return ValueTask.CompletedTask; + return; } var requestedScopes = context.Request.GetScopes(); @@ -33,8 +44,86 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle { context.Reject(OpenIddictConstants.Errors.InvalidGrant, "obs:incident tokens require fresh authentication; refresh is not permitted."); logger.LogWarning("Refresh token validation failed for client {ClientId}: obs:incident scope requires fresh authentication.", context.ClientId ?? context.Request.ClientId ?? ""); + return; } - return ValueTask.CompletedTask; + var senderConstraint = refreshPrincipal?.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType); + if (string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal)) + { + if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!).ConfigureAwait(false)) + { + return; + } + } + } + + private async ValueTask EnsureMtlsBindingAsync(OpenIddictServerEvents.ValidateTokenRequestContext context, ClaimsPrincipal principal) + { + var clientId = context.ClientId ?? context.Request.ClientId; + if (string.IsNullOrWhiteSpace(clientId)) + { + context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required for mTLS-bound refresh tokens."); + logger.LogWarning("mTLS refresh validation failed: client id missing."); + return false; + } + + var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); + if (clientDocument is null) + { + context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client."); + logger.LogWarning("mTLS refresh validation failed for {ClientId}: client not found.", clientId); + return false; + } + + if (!TryGetHttpContext(context.Transaction, out var httpContext)) + { + context.Reject(OpenIddictConstants.Errors.InvalidClient, "Sender certificate is required for this token."); + logger.LogWarning("mTLS refresh validation failed for {ClientId}: HTTP context unavailable.", clientId); + return false; + } + + var validation = await certificateValidator.ValidateAsync(httpContext, clientDocument, context.CancellationToken).ConfigureAwait(false); + if (!validation.Succeeded || + string.IsNullOrWhiteSpace(validation.HexThumbprint) || + string.IsNullOrWhiteSpace(validation.ConfirmationThumbprint)) + { + context.Reject(OpenIddictConstants.Errors.InvalidClient, "Sender certificate validation failed."); + logger.LogWarning( + "mTLS refresh validation failed for {ClientId}: certificate validation error {Reason}.", + clientId, + validation.Error ?? "unknown"); + return false; + } + + var expectedHex = principal.GetClaim(AuthorityOpenIddictConstants.MtlsCertificateHexClaimType); + if (!string.IsNullOrWhiteSpace(expectedHex) && + !string.Equals(expectedHex, validation.HexThumbprint, StringComparison.OrdinalIgnoreCase)) + { + context.Reject(OpenIddictConstants.Errors.InvalidClient, "Sender certificate mismatch."); + logger.LogWarning( + "mTLS refresh validation failed for {ClientId}: certificate thumbprint {Thumbprint} did not match refresh token binding {Expected}.", + clientId, + validation.HexThumbprint, + expectedHex); + return false; + } + + context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Mtls; + context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty] = validation.ConfirmationThumbprint; + context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty] = validation.HexThumbprint; + return true; + } + + private static bool TryGetHttpContext(OpenIddictServerTransaction transaction, out HttpContext? httpContext) + { + if (transaction.Properties.TryGetValue(typeof(HttpContext).FullName!, out var property) && + property is HttpContext typedContext) + { + httpContext = typedContext; + return true; + } + + httpContext = null; + return false; } } diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index 954b310b8..ca0092bb4 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -241,15 +241,14 @@ if (authorityConfigured) } } - builder.Services.AddAuthorization(options => - { +builder.Services.AddAuthorization(options => +{ options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray()); options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnView); options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest); options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead); options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify); }); -} var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath); builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); @@ -1615,4 +1614,3 @@ static async Task InitializeMongoAsync(WebApplication app) } public partial class Program; - \ No newline at end of file diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs index 352443d55..24fb05b87 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs @@ -1588,6 +1588,13 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false"); Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false"); Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false"); + const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET"; + if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey)) + { + var previousSecret = Environment.GetEnvironmentVariable(TestSecretKey); + _additionalPreviousEnvironment[TestSecretKey] = previousSecret; + Environment.SetEnvironmentVariable(TestSecretKey, TestSigningSecret); + } if (environmentOverrides is not null) { foreach (var kvp in environmentOverrides)