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
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
| ||||