feat: Update Sprint 110 documentation and enhance Advisory AI tests for determinism and mTLS validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
master
2025-11-08 23:28:41 +02:00
parent ae69b1a8a1
commit d71c81e45d
9 changed files with 395 additions and 19 deletions

View File

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

View File

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

View File

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

View File

@@ -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<KeyValuePair<string, object?>> Tags)>();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == AdvisoryPipelineMetrics.MeterName)
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<double>((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<string, string> { ["section"] = "Summary" });
var chunkTwo = AdvisoryChunk.Create(
"doc-1",
"chunk-2",
"Impact",
"para-2",
"Impact details",
new Dictionary<string, string> { ["section"] = "Impact" });
var structured = ImmutableArray.Create(chunkOne, chunkTwo);
var plan = new AdvisoryTaskPlan(
request,
cacheKey,
promptTemplate: "prompts/advisory/summary.liquid",
structuredChunks: structured,
vectorResults: ImmutableArray<AdvisoryVectorResult>.Empty,
sbomContext: null,
dependencyAnalysis: DependencyAnalysisResult.Empty("artifact-1"),
budget: new AdvisoryTaskBudget { PromptTokens = 512, CompletionTokens = 256 },
metadata: ImmutableDictionary<string, string>.Empty.Add("advisory_key", "adv-key"));
return plan;
}
private sealed class StubPromptAssembler : IAdvisoryPromptAssembler
{
public Task<AdvisoryPrompt> AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken)
@@ -166,6 +245,33 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
}
}
private sealed class PartialCitationPromptAssembler : IAdvisoryPromptAssembler
{
private readonly ImmutableArray<AdvisoryPromptCitation> _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<AdvisoryPrompt> AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken)
{
var metadata = ImmutableDictionary<string, string>.Empty.Add("advisory_key", plan.Request.AdvisoryKey);
var diagnostics = ImmutableDictionary<string, string>.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;

View File

@@ -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<VectorRetrievalMatch>.Empty));
@@ -72,7 +92,7 @@ public sealed class AdvisoryPlanCacheTests
new KeyValuePair<string, string>("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

View File

@@ -4352,6 +4352,24 @@ internal sealed class RecordingCertificateValidator : IAuthorityClientCertificat
}
}
internal sealed class StubCertificateValidator : IAuthorityClientCertificateValidator
{
private readonly Func<AuthorityClientCertificateValidationResult> factory;
public StubCertificateValidator(Func<AuthorityClientCertificateValidationResult> factory)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
public bool Invoked { get; private set; }
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
{
Invoked = true;
return ValueTask.FromResult(factory());
}
}
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public ValueTask<IClientSessionHandle> 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<ValidateRefreshTokenGrantHandler>.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<ValidateRefreshTokenGrantHandler>.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<ValidateRefreshTokenGrantHandler>.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<string, string>
{
["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
{

View File

@@ -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<OpenIddictServerEvents.ValidateTokenRequestContext>
{
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly ILogger<ValidateRefreshTokenGrantHandler> logger;
public ValidateRefreshTokenGrantHandler(ILogger<ValidateRefreshTokenGrantHandler> logger)
public ValidateRefreshTokenGrantHandler(
IAuthorityClientStore clientStore,
IAuthorityClientCertificateValidator certificateValidator,
ILogger<ValidateRefreshTokenGrantHandler> 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 ?? "<unknown>");
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<bool> 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;
}
}

View File

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

View File

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