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-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: 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-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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -137,3 +137,11 @@ All endpoints accept `profile` parameter (default `fips-local`) and return `outp
|
|||||||
- **Worker** — background service draining the advisory pipeline queue (file-backed stub) pending integration with shared transport.
|
- **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.
|
- 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`.
|
- 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-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-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-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-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.
|
> 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-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 – 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-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.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Linq;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using StellaOps.AdvisoryAI.Abstractions;
|
using StellaOps.AdvisoryAI.Abstractions;
|
||||||
using StellaOps.AdvisoryAI.Documents;
|
using StellaOps.AdvisoryAI.Documents;
|
||||||
@@ -118,6 +119,44 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
|
|||||||
Math.Abs(measurement.Value - 1d) < 0.0001);
|
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)
|
private static AdvisoryTaskPlan BuildMinimalPlan(string cacheKey)
|
||||||
{
|
{
|
||||||
var request = new AdvisoryTaskRequest(
|
var request = new AdvisoryTaskRequest(
|
||||||
@@ -148,6 +187,46 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
|
|||||||
return plan;
|
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
|
private sealed class StubPromptAssembler : IAdvisoryPromptAssembler
|
||||||
{
|
{
|
||||||
public Task<AdvisoryPrompt> AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken)
|
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 sealed class StubGuardrailPipeline : IAdvisoryGuardrailPipeline
|
||||||
{
|
{
|
||||||
private readonly AdvisoryGuardrailResult _result;
|
private readonly AdvisoryGuardrailResult _result;
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ public sealed class AdvisoryPlanCacheTests
|
|||||||
retrieved.Should().BeNull();
|
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)
|
private static InMemoryAdvisoryPlanCache CreateCache(FakeTimeProvider timeProvider, TimeSpan? ttl = null)
|
||||||
{
|
{
|
||||||
var options = Options.Create(new AdvisoryPlanCacheOptions
|
var options = Options.Create(new AdvisoryPlanCacheOptions
|
||||||
@@ -59,9 +79,9 @@ public sealed class AdvisoryPlanCacheTests
|
|||||||
return new InMemoryAdvisoryPlanCache(options, timeProvider);
|
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 chunk = AdvisoryChunk.Create("doc-1", "chunk-1", "section", "para", "text");
|
||||||
var structured = ImmutableArray.Create(chunk);
|
var structured = ImmutableArray.Create(chunk);
|
||||||
var vectors = ImmutableArray.Create(new AdvisoryVectorResult("query", ImmutableArray<VectorRetrievalMatch>.Empty));
|
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())
|
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
|
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
|
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||||
{
|
{
|
||||||
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
|
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -4865,6 +4883,118 @@ public class ObservabilityIncidentTokenHandlerTests
|
|||||||
|
|
||||||
Assert.False(context.IsRejected);
|
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
|
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)
|
public static string ConvertThumbprintToString(object thumbprint)
|
||||||
=> thumbprint switch
|
=> thumbprint switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
using OpenIddict.Extensions;
|
using OpenIddict.Extensions;
|
||||||
using OpenIddict.Server;
|
using OpenIddict.Server;
|
||||||
using StellaOps.Auth.Abstractions;
|
using StellaOps.Auth.Abstractions;
|
||||||
|
using StellaOps.Authority.Security;
|
||||||
|
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||||
|
|
||||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||||
|
|
||||||
internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||||
{
|
{
|
||||||
|
private readonly IAuthorityClientStore clientStore;
|
||||||
|
private readonly IAuthorityClientCertificateValidator certificateValidator;
|
||||||
private readonly ILogger<ValidateRefreshTokenGrantHandler> logger;
|
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));
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
if (!context.Request.IsRefreshTokenGrantType())
|
if (!context.Request.IsRefreshTokenGrantType())
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestedScopes = context.Request.GetScopes();
|
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.");
|
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>");
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ if (authorityConfigured)
|
|||||||
options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead);
|
options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead);
|
||||||
options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify);
|
options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
|
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
|
||||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||||
@@ -1615,4 +1614,3 @@ static async Task InitializeMongoAsync(WebApplication app)
|
|||||||
}
|
}
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
| |||||||