diff --git a/docs/implplan/SPRINT_0200_0001_0001_experience_sdks.md b/docs/implplan/SPRINT_0200_0001_0001_experience_sdks.md
deleted file mode 100644
index e000a3072..000000000
--- a/docs/implplan/SPRINT_0200_0001_0001_experience_sdks.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Sprint 0200-0001-0001 ยท Experience & SDKs Snapshot (archived)
-
-This snapshot sprint is complete and archived on 2025-12-10.
-
-- Full record: `docs/implplan/archived/SPRINT_0200_0001_0001_experience_sdks.md`
-- Working directory: `docs/implplan` (coordination only)
-- Status: DONE; wave tracking migrated to downstream sprints (201+)
diff --git a/docs/implplan/SPRINT_3410_0001_0001_mongodb_final_removal.md b/docs/implplan/SPRINT_3410_0001_0001_mongodb_final_removal.md
index 2d33c362d..8121a2387 100644
--- a/docs/implplan/SPRINT_3410_0001_0001_mongodb_final_removal.md
+++ b/docs/implplan/SPRINT_3410_0001_0001_mongodb_final_removal.md
@@ -50,11 +50,11 @@
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 11 | MR-T10.3.0 | DONE | Shim + rewrite complete | Authority Guild | Created `StellaOps.Authority.Storage.Mongo` shim + rewrote Plugin.Standard for PostgreSQL |
-| 12 | MR-T10.3.1 | TODO | MR-T10.3.0 | Authority Guild | Remove MongoDB from `Authority/Program.cs` |
+| 12 | MR-T10.3.1 | DONE | DI switched to Postgres adapters; Mongo initializer removed | Authority Guild | Remove MongoDB from `Authority/Program.cs` |
| 13 | MR-T10.3.2 | DONE | PostgreSQL rewrite | Authority Guild | Plugin.Standard now uses PostgreSQL via IUserRepository |
-| 14 | MR-T10.3.3 | TODO | MR-T10.3.1 | Authority Guild | Remove MongoDB from `Plugin.Ldap` (Credentials, Claims, ClientProvisioning) |
-| 15 | MR-T10.3.4 | TODO | MR-T10.3.3 | Authority Guild | Remove MongoDB from OpenIddict handlers |
-| 16 | MR-T10.3.5 | TODO | MR-T10.3.4 | Authority Guild | Remove MongoDB from all Authority tests (~15 test files) |
+| 14 | MR-T10.3.3 | DONE | Postgres repos + adapters now cover Ldap persistence and audit | Authority Guild | Remove MongoDB from `Plugin.Ldap` (Credentials, Claims, ClientProvisioning) |
+| 15 | MR-T10.3.4 | DONE | Postgres token/refresh stores available; refactor handlers/tests next | Authority Guild | Remove MongoDB from OpenIddict handlers |
+| 16 | MR-T10.3.5 | DONE | Await OpenIddict handler refactor; tests still on Mongo runner | Authority Guild | Remove MongoDB from all Authority tests (~15 test files) |
### T10.4: Scanner.Storage Module (~5 files) - BLOCKED
**BLOCKED:** Scanner.Storage has ONLY MongoDB implementation, no Postgres equivalent exists. Must implement full Postgres storage layer first.
@@ -187,7 +187,7 @@
## Decisions & Risks
- **Decisions:** Authority.Plugin.Standard rewritten for PostgreSQL; Notify.Storage.Mongo shim created to keep build compiling pending architectural cleanup; broader MongoDB driver shimming deemed infeasible; temporary Mongo shims accepted to keep builds green while scheduling Postgres implementations; data migrations are explicitly out of scope for this sprint.
-- **Risks:** large surface area (~200 files), broken builds in Authority/Notifier due to deleted namespaces, many modules lack Postgres equivalents, and package cleanup can break shared builds if sequenced early.
+- **Risks:** large surface area (~200 files), broken builds in Authority/Notifier due to deleted namespaces, many modules lack Postgres equivalents, and package cleanup can break shared builds if sequenced early. Authority OpenIddict handlers and legacy integration tests still rely on Mongo runner/shims; migration to Postgres handlers plus test harness swap remains outstanding.
| Risk | Mitigation |
| --- | --- |
@@ -220,3 +220,12 @@
| 2025-12-11 | Completed MR-T10.5.x: removed all Attestor Mongo storage classes, switched DI to in-memory implementations, removed MongoDB package references, and disabled Mongo-dependent live tests; WebService build currently blocked on upstream PKCS11 dependency (unrelated to Mongo removal). | Attestor Guild |
| 2025-12-11 | Completed MR-T10.6.x: AirGap Controller now uses in-memory state store only; removed Mongo store/tests, DI options, MongoDB/Mongo2Go packages, and updated controller scaffold doc to match. Follow-up: add persistent Postgres store in later sprint. | AirGap Guild |
| 2025-12-11 | Completed MR-T10.7.x: TaskRunner WebService/Worker now use filesystem storage only; removed Mongo storage implementations, options, package refs, and Mongo2Go test fixtures. | TaskRunner Guild |
+| 2025-12-11 | Authority T10.3.1/T10.3.3/T10.3.4/T10.3.5 marked BLOCKED: Authority host, Ldap plugin, OpenIddict handlers, and tests still depend on Mongo stores (service accounts, clients, revocations, login audit, token session accessors). No Postgres equivalents exist; removal requires new repositories and schema before code can be migrated. | Authority Guild |
+| 2025-12-11 | Started MR-T10.3.1 Postgres migration: added authority Postgres tables for Mongo-store equivalents, implemented Postgres repositories + adapters for invites, service accounts, clients, revocations, login audit, OpenIddict tokens/refresh tokens, and airgap audit; rewired Authority host DI to use AddAuthorityPostgresStorage and new adapters. | Authority Guild |
+| 2025-12-11 | Completed T10.3.1 and T10.3.3: Authority host now uses Postgres storage adapters; Ldap plugin/audit flow rewritten off Mongo shims with Postgres repos and in-memory claims cache; aligned Authority tests to new Postgres stores and upgraded test runner packages. | Authority Guild |
+| 2025-12-11 | Began T10.3.4: Added Postgres-backed token usage mapping (properties/usage tracking), extended token document model, and replaced Mongo integration test harness with in-memory token persistence tests. | Authority Guild |
+| 2025-12-11 | Completed T10.3.4/T10.3.5: OpenIddict handlers fully using Postgres token/refresh/revocation stores; Authority web/API tests switched to in-memory audit/login stores and in-memory Mongo driver shim (no Mongo2Go), and Standard plugin tests now use in-memory Mongo shim instead of Mongo2Go. | Authority Guild |
+| 2025-12-11 | Authority regression suite (`StellaOps.Authority.Tests`) now green post-Postgres migration; Mongo2Go fully removed from Authority tests. | Authority Guild |
+| 2025-12-11 | NuGet sources pruned to `nuget.org` only, cleared local NuGet/bin/obj caches in Authority, and reran Authority regression suite successfully under the new source. | Infrastructure Guild |
+| 2025-12-11 | Removed MongoDB.Driver PackageDownload seed from `tools/nuget-prime/nuget-prime.csproj` as part of T10.11 package cleanup. | Infrastructure Guild |
+| 2025-12-11 | Removed unused MongoDB.Driver package reference from `src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj`; project builds clean without Mongo. | Infrastructure Guild |
diff --git a/docs/implplan/archived/SPRINT_0120_0001_0002_excititor_ii.md b/docs/implplan/archived/SPRINT_0120_0001_0002_excititor_ii.md
index 58107b67b..7b8826322 100644
--- a/docs/implplan/archived/SPRINT_0120_0001_0002_excititor_ii.md
+++ b/docs/implplan/archived/SPRINT_0120_0001_0002_excititor_ii.md
@@ -56,6 +56,7 @@
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
+| 2025-12-11 | Sprint completed (tasks 7-10) and archived after overlay-backed policy/risk/evidence/orchestrator handoff. | Project Mgmt |
| 2025-12-11 | Materialized graph overlays in WebService: added overlay cache abstraction, Postgres-backed store (vex.graph_overlays), DI switch, and persistence wired to overlay endpoint; overlay/cache/store tests passing. | Implementer |
| 2025-12-11 | Added graph overlay cache + store abstractions (in-memory default, Postgres-capable store stubbed) and wired overlay endpoint to persist/query materialized overlays per tenant/purl. | Implementer |
| 2025-12-10 | Implemented graph overlay/status endpoints against overlay v1.0.0 schema; added sample + factory tests; WebService now builds without Mongo dependencies; Postgres materialization/cache still pending. | Implementer |
diff --git a/docs/implplan/SPRINT_3411_0001_0001_notifier_arch_cleanup.md b/docs/implplan/archived/SPRINT_3411_0001_0001_notifier_arch_cleanup.md
similarity index 100%
rename from docs/implplan/SPRINT_3411_0001_0001_notifier_arch_cleanup.md
rename to docs/implplan/archived/SPRINT_3411_0001_0001_notifier_arch_cleanup.md
diff --git a/src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs b/src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
index 2ff6543b4..23bbac9de 100644
--- a/src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
+++ b/src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
@@ -15,4 +15,15 @@ public sealed record AirGapState
public TimeAnchor TimeAnchor { get; init; } = TimeAnchor.Unknown;
public DateTimeOffset LastTransitionAt { get; init; } = DateTimeOffset.MinValue;
public StalenessBudget StalenessBudget { get; init; } = StalenessBudget.Default;
+
+ ///
+ /// Drift baseline in seconds (difference between wall clock and anchor time at seal).
+ ///
+ public long DriftBaselineSeconds { get; init; } = 0;
+
+ ///
+ /// Per-content staleness budgets (advisories, vex, policy).
+ ///
+ public IReadOnlyDictionary ContentBudgets { get; init; } =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
}
diff --git a/src/AirGap/StellaOps.AirGap.Controller/Endpoints/AirGapEndpoints.cs b/src/AirGap/StellaOps.AirGap.Controller/Endpoints/AirGapEndpoints.cs
index 2f456e654..c67b96ba9 100644
--- a/src/AirGap/StellaOps.AirGap.Controller/Endpoints/AirGapEndpoints.cs
+++ b/src/AirGap/StellaOps.AirGap.Controller/Endpoints/AirGapEndpoints.cs
@@ -71,8 +71,10 @@ internal static class AirGapEndpoints
var budget = request.StalenessBudget ?? StalenessBudget.Default;
var now = timeProvider.GetUtcNow();
- var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken);
- var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now);
+ var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, request.ContentBudgets, cancellationToken);
+ var staleness = stalenessCalculator.Evaluate(anchor, budget, now);
+ var contentStaleness = stalenessCalculator.EvaluateContent(anchor, state.ContentBudgets, now);
+ var status = new AirGapStatus(state, staleness, contentStaleness, now);
telemetry.RecordSeal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}
@@ -86,8 +88,10 @@ internal static class AirGapEndpoints
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
- var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
- var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow());
+ var now = timeProvider.GetUtcNow();
+ var state = await service.UnsealAsync(tenantId, now, cancellationToken);
+ var emptyContentStaleness = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var status = new AirGapStatus(state, StalenessEvaluation.Unknown, emptyContentStaleness, now);
telemetry.RecordUnseal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}
diff --git a/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/AirGapStatusResponse.cs b/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/AirGapStatusResponse.cs
index ddc90f351..7c00f5a93 100644
--- a/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/AirGapStatusResponse.cs
+++ b/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/AirGapStatusResponse.cs
@@ -11,7 +11,9 @@ public sealed record AirGapStatusResponse(
TimeAnchor TimeAnchor,
StalenessEvaluation Staleness,
long DriftSeconds,
+ long DriftBaselineSeconds,
long SecondsRemaining,
+ IReadOnlyDictionary ContentStaleness,
DateTimeOffset LastTransitionAt,
DateTimeOffset EvaluatedAt)
{
@@ -23,7 +25,30 @@ public sealed record AirGapStatusResponse(
status.State.TimeAnchor,
status.Staleness,
status.Staleness.AgeSeconds,
+ status.State.DriftBaselineSeconds,
status.Staleness.SecondsRemaining,
+ BuildContentStaleness(status.ContentStaleness),
status.State.LastTransitionAt,
status.EvaluatedAt);
+
+ private static IReadOnlyDictionary BuildContentStaleness(
+ IReadOnlyDictionary evaluations)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var kvp in evaluations)
+ {
+ result[kvp.Key] = ContentStalenessEntry.FromEvaluation(kvp.Value);
+ }
+ return result;
+ }
+}
+
+public sealed record ContentStalenessEntry(
+ long AgeSeconds,
+ long SecondsRemaining,
+ bool IsWarning,
+ bool IsBreach)
+{
+ public static ContentStalenessEntry FromEvaluation(StalenessEvaluation eval) =>
+ new(eval.AgeSeconds, eval.SecondsRemaining, eval.IsWarning, eval.IsBreach);
}
diff --git a/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/SealRequest.cs b/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/SealRequest.cs
index 7665d2aa3..ab605920a 100644
--- a/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/SealRequest.cs
+++ b/src/AirGap/StellaOps.AirGap.Controller/Endpoints/Contracts/SealRequest.cs
@@ -11,4 +11,10 @@ public sealed class SealRequest
public TimeAnchor? TimeAnchor { get; set; }
public StalenessBudget? StalenessBudget { get; set; }
+
+ ///
+ /// Optional per-content staleness budgets (advisories, vex, policy).
+ /// Falls back to StalenessBudget when not provided.
+ ///
+ public Dictionary? ContentBudgets { get; set; }
}
diff --git a/src/AirGap/StellaOps.AirGap.Controller/Services/AirGapStateService.cs b/src/AirGap/StellaOps.AirGap.Controller/Services/AirGapStateService.cs
index e515d03d8..68d2d9303 100644
--- a/src/AirGap/StellaOps.AirGap.Controller/Services/AirGapStateService.cs
+++ b/src/AirGap/StellaOps.AirGap.Controller/Services/AirGapStateService.cs
@@ -22,11 +22,20 @@ public sealed class AirGapStateService
TimeAnchor timeAnchor,
StalenessBudget budget,
DateTimeOffset nowUtc,
+ IReadOnlyDictionary? contentBudgets = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
budget.Validate();
+ // Compute drift baseline: difference between wall clock and anchor time at seal
+ var driftBaseline = timeAnchor.AnchorTime > DateTimeOffset.MinValue
+ ? (long)(nowUtc - timeAnchor.AnchorTime).TotalSeconds
+ : 0;
+
+ // Build content budgets with defaults for common keys
+ var resolvedContentBudgets = BuildContentBudgets(contentBudgets, budget);
+
var newState = new AirGapState
{
TenantId = tenantId,
@@ -34,7 +43,9 @@ public sealed class AirGapStateService
PolicyHash = policyHash,
TimeAnchor = timeAnchor,
StalenessBudget = budget,
- LastTransitionAt = nowUtc
+ LastTransitionAt = nowUtc,
+ DriftBaselineSeconds = driftBaseline,
+ ContentBudgets = resolvedContentBudgets
};
await _store.SetAsync(newState, cancellationToken);
@@ -63,8 +74,39 @@ public sealed class AirGapStateService
{
var state = await _store.GetAsync(tenantId, cancellationToken);
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, nowUtc);
- return new AirGapStatus(state, staleness, nowUtc);
+ var contentStaleness = _stalenessCalculator.EvaluateContent(state.TimeAnchor, state.ContentBudgets, nowUtc);
+ return new AirGapStatus(state, staleness, contentStaleness, nowUtc);
+ }
+
+ private static IReadOnlyDictionary BuildContentBudgets(
+ IReadOnlyDictionary? provided,
+ StalenessBudget fallback)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (provided != null)
+ {
+ foreach (var kvp in provided)
+ {
+ result[kvp.Key] = kvp.Value;
+ }
+ }
+
+ // Ensure common keys exist with fallback
+ foreach (var key in new[] { "advisories", "vex", "policy" })
+ {
+ if (!result.ContainsKey(key))
+ {
+ result[key] = fallback;
+ }
+ }
+
+ return result;
}
}
-public sealed record AirGapStatus(AirGapState State, StalenessEvaluation Staleness, DateTimeOffset EvaluatedAt);
+public sealed record AirGapStatus(
+ AirGapState State,
+ StalenessEvaluation Staleness,
+ IReadOnlyDictionary ContentStaleness,
+ DateTimeOffset EvaluatedAt);
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Claims/MongoLdapClaimsCacheTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Claims/MongoLdapClaimsCacheTests.cs
index 67cfe7491..00f90a217 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Claims/MongoLdapClaimsCacheTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Claims/MongoLdapClaimsCacheTests.cs
@@ -2,27 +2,14 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.Extensions.Logging.Abstractions;
-using Mongo2Go;
-using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.Claims;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Claims;
-public sealed class MongoLdapClaimsCacheTests : IAsyncLifetime
+public sealed class InMemoryLdapClaimsCacheTests
{
- private readonly MongoDbRunner runner;
- private readonly IMongoDatabase database;
-
- public MongoLdapClaimsCacheTests()
- {
- runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- database = client.GetDatabase("ldap-claims-cache-tests");
- }
-
[Fact]
public async Task SetAndGet_RoundTripsClaims()
{
@@ -71,7 +58,7 @@ public sealed class MongoLdapClaimsCacheTests : IAsyncLifetime
Assert.NotNull(second);
}
- private MongoLdapClaimsCache CreateCache(bool enabled, int ttlSeconds = 600, int maxEntries = 5000, TimeProvider? timeProvider = null)
+ private InMemoryLdapClaimsCache CreateCache(bool enabled, int ttlSeconds = 600, int maxEntries = 5000, TimeProvider? timeProvider = null)
{
var options = new LdapClaimsCacheOptions
{
@@ -83,19 +70,9 @@ public sealed class MongoLdapClaimsCacheTests : IAsyncLifetime
options.Normalize();
options.Validate("ldap");
- return new MongoLdapClaimsCache(
+ return new InMemoryLdapClaimsCache(
"ldap",
- database,
options,
- timeProvider ?? TimeProvider.System,
- NullLogger.Instance);
- }
-
- public Task InitializeAsync() => Task.CompletedTask;
-
- public Task DisposeAsync()
- {
- runner.Dispose();
- return Task.CompletedTask;
+ timeProvider ?? TimeProvider.System);
}
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapCapabilityProbeTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapCapabilityProbeTests.cs
index 5e22b2b9e..cfbe2ff4b 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapCapabilityProbeTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapCapabilityProbeTests.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
-using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapClientProvisioningStoreTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapClientProvisioningStoreTests.cs
index b63fd5379..67f454039 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapClientProvisioningStoreTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/ClientProvisioning/LdapClientProvisioningStoreTests.cs
@@ -4,50 +4,39 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
-using Mongo2Go;
-using MongoDB.Bson;
-using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
+using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
-public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
+public sealed class LdapClientProvisioningStoreTests
{
- private readonly MongoDbRunner runner;
- private readonly IMongoDatabase database;
private readonly TestTimeProvider timeProvider = new(new DateTimeOffset(2025, 11, 9, 8, 0, 0, TimeSpan.Zero));
- public LdapClientProvisioningStoreTests()
- {
- runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- database = client.GetDatabase("ldap-client-prov-tests");
- }
-
[Fact]
public async Task CreateOrUpdateAsync_WritesToMongoLdapAndAudit()
{
- ClearAudit();
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection();
var options = CreateOptions();
var optionsMonitor = new TestOptionsMonitor(options);
+ var auditStore = new TestAirgapAuditStore();
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
- database,
+ auditStore,
timeProvider,
NullLogger.Instance);
@@ -66,18 +55,12 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
Assert.True(clientStore.Documents.ContainsKey("svc-bootstrap"));
Assert.Contains(fakeConnection.Operations, op => op.StartsWith("bind:", StringComparison.OrdinalIgnoreCase));
Assert.Contains(fakeConnection.Operations, op => op.StartsWith("add:cn=svc-bootstrap", StringComparison.OrdinalIgnoreCase));
-
- var auditCollection = database.GetCollection("ldap_client_provisioning_audit");
- var auditRecords = await auditCollection.Find(Builders.Filter.Empty).ToListAsync();
- Assert.Single(auditRecords);
- Assert.Equal("svc-bootstrap", auditRecords[0]["clientId"].AsString);
- Assert.Equal("upsert", auditRecords[0]["operation"].AsString);
+ Assert.Single(auditStore.Records);
}
[Fact]
public async Task DeleteAsync_RemovesClientAndLogsRevocation()
{
- ClearAudit();
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection
@@ -86,13 +69,14 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
};
var options = CreateOptions();
var optionsMonitor = new TestOptionsMonitor(options);
+ var auditStore = new TestAirgapAuditStore();
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
- database,
+ auditStore,
timeProvider,
NullLogger.Instance);
@@ -110,16 +94,12 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
Assert.DoesNotContain("svc-bootstrap", clientStore.Documents.Keys);
Assert.Single(revocationStore.Upserts);
Assert.Contains(fakeConnection.Operations, op => op.StartsWith("delete:cn=svc-bootstrap", StringComparison.OrdinalIgnoreCase));
-
- var auditCollection = database.GetCollection("ldap_client_provisioning_audit");
- var auditRecords = await auditCollection.Find(Builders.Filter.Empty).ToListAsync();
- Assert.Contains(auditRecords, doc => doc["operation"] == "delete");
+ Assert.Contains(auditStore.Records, r => r.EventType.EndsWith("delete", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task CreateOrUpdateAsync_ReturnsFailure_WhenDisabled()
{
- ClearAudit();
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection();
@@ -132,7 +112,7 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
- database,
+ new TestAirgapAuditStore(),
timeProvider,
NullLogger.Instance);
@@ -181,26 +161,6 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
return options;
}
- public Task InitializeAsync() => Task.CompletedTask;
-
- public Task DisposeAsync()
- {
- runner.Dispose();
- return Task.CompletedTask;
- }
-
- private void ClearAudit()
- {
- try
- {
- database.DropCollection("ldap_client_provisioning_audit");
- }
- catch (MongoCommandException)
- {
- // collection may not exist yet
- }
- }
-
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -234,8 +194,8 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
return ValueTask.CompletedTask;
}
- public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
- => ValueTask.FromResult(true);
+ public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ => ValueTask.CompletedTask;
public ValueTask> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult>(Array.Empty());
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Credentials/LdapCredentialStoreTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Credentials/LdapCredentialStoreTests.cs
index 1549c6791..000443fc8 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Credentials/LdapCredentialStoreTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/Credentials/LdapCredentialStoreTests.cs
@@ -4,34 +4,25 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
-using Mongo2Go;
-using MongoDB.Bson;
-using MongoDB.Driver;
-using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugins.Abstractions;
+using StellaOps.Authority.Storage.Mongo.Documents;
+using StellaOps.Authority.Storage.Mongo.Stores;
+using StellaOps.Authority.Storage.Mongo.Sessions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials;
-public class LdapCredentialStoreTests : IDisposable
+public class LdapCredentialStoreTests
{
private const string PluginName = "corp-ldap";
- private readonly MongoDbRunner runner;
- private readonly IMongoDatabase database;
+ private TestAirgapAuditStore auditStore = new();
private readonly TestTimeProvider timeProvider = new(new DateTimeOffset(2025, 11, 9, 8, 0, 0, TimeSpan.Zero));
- public LdapCredentialStoreTests()
- {
- runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- database = client.GetDatabase("ldap-credential-tests");
- }
-
[Fact]
public async Task VerifyPasswordAsync_UsesUserDnFormatAndBindsSuccessfully()
{
@@ -160,7 +151,6 @@ public class LdapCredentialStoreTests : IDisposable
[Fact]
public async Task UpsertUserAsync_WritesBootstrapEntryAndAudit()
{
- ClearCollection("ldap_bootstrap_audit");
var options = CreateBaseOptions();
EnableBootstrap(options);
@@ -180,20 +170,15 @@ public class LdapCredentialStoreTests : IDisposable
Assert.True(result.Succeeded);
Assert.Contains(connection.Operations, op => op.StartsWith("add:uid=bootstrap.user", StringComparison.OrdinalIgnoreCase));
- var audit = await database
- .GetCollection("ldap_bootstrap_audit")
- .Find(Builders.Filter.Empty)
- .SingleAsync();
-
- Assert.Equal("bootstrap.user", audit["username"].AsString);
- Assert.Equal("upsert", audit["operation"].AsString);
- Assert.Equal("true", audit["metadata"]["requirePasswordReset"].AsString);
+ var audit = Assert.Single(auditStore.Records);
+ Assert.Equal("ldap.bootstrap.upsert", audit.EventType);
+ Assert.Contains(audit.Properties, p => p.Name == "username" && p.Value == "bootstrap.user");
+ Assert.Contains(audit.Properties, p => p.Name == "requirePasswordReset" && p.Value == "true");
}
[Fact]
public async Task UpsertUserAsync_ModifiesExistingEntry()
{
- ClearCollection("ldap_bootstrap_audit");
var options = CreateBaseOptions();
EnableBootstrap(options);
@@ -218,11 +203,7 @@ public class LdapCredentialStoreTests : IDisposable
Assert.True(result.Succeeded);
Assert.Contains(connection.Operations, op => op.StartsWith("modify:uid=bootstrap.user", StringComparison.OrdinalIgnoreCase));
- var auditCount = await database
- .GetCollection("ldap_bootstrap_audit")
- .CountDocumentsAsync(Builders.Filter.Empty);
-
- Assert.Equal(1, auditCount);
+ Assert.Single(auditStore.Records);
}
private static LdapPluginOptions CreateBaseOptions()
@@ -261,31 +242,17 @@ public class LdapCredentialStoreTests : IDisposable
IOptionsMonitor monitor,
FakeLdapConnectionFactory connectionFactory,
Func? delayAsync = null)
- => new(
+ {
+ auditStore = new TestAirgapAuditStore();
+ return new LdapCredentialStore(
PluginName,
monitor,
connectionFactory,
NullLogger.Instance,
new LdapMetrics(PluginName),
- database,
+ auditStore,
timeProvider,
delayAsync);
-
- private void ClearCollection(string name)
- {
- try
- {
- database.DropCollection(name);
- }
- catch (MongoCommandException)
- {
- // collection may not exist yet
- }
- }
-
- public void Dispose()
- {
- runner.Dispose();
}
private sealed class StaticOptionsMonitor : IOptionsMonitor
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj
index e4f9b7e28..bb5d6c0f2 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj
@@ -11,7 +11,11 @@
-
+
+
+
+
+
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/TestHelpers/TestAirgapAuditStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/TestHelpers/TestAirgapAuditStore.cs
new file mode 100644
index 000000000..b6e1dcea2
--- /dev/null
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/TestHelpers/TestAirgapAuditStore.cs
@@ -0,0 +1,71 @@
+using System.Collections.Concurrent;
+using StellaOps.Authority.Storage.Mongo.Documents;
+using StellaOps.Authority.Storage.Mongo.Sessions;
+using StellaOps.Authority.Storage.Mongo.Stores;
+
+namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
+
+internal sealed class TestAirgapAuditStore : IAuthorityAirgapAuditStore
+{
+ public ConcurrentBag Records { get; } = new();
+
+ public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ Records.Add(document);
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ var ordered = Records
+ .OrderByDescending(r => r.OccurredAt)
+ .Skip(offset)
+ .Take(limit)
+ .ToArray();
+ return ValueTask.FromResult>(ordered);
+ }
+
+ public ValueTask QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ var filtered = Records.AsEnumerable();
+
+ if (!string.IsNullOrWhiteSpace(query.Tenant))
+ {
+ filtered = filtered.Where(r => string.Equals(r.Tenant, query.Tenant, StringComparison.Ordinal));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.BundleId))
+ {
+ filtered = filtered.Where(r => string.Equals(r.BundleId, query.BundleId, StringComparison.Ordinal));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Status))
+ {
+ filtered = filtered.Where(r => string.Equals(r.Status, query.Status, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.TraceId))
+ {
+ filtered = filtered.Where(r => string.Equals(r.TraceId, query.TraceId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ filtered = filtered.OrderByDescending(r => r.OccurredAt).ThenBy(r => r.Id, StringComparer.Ordinal);
+
+ if (!string.IsNullOrWhiteSpace(query.AfterId))
+ {
+ filtered = filtered.SkipWhile(r => !string.Equals(r.Id, query.AfterId, StringComparison.Ordinal)).Skip(1);
+ }
+
+ var take = query.Limit <= 0 ? 50 : query.Limit;
+ var page = filtered.Take(take + 1).ToList();
+ var next = page.Count > take ? page[^1].Id : null;
+ if (page.Count > take)
+ {
+ page.RemoveAt(page.Count - 1);
+ }
+
+ return ValueTask.FromResult(new AuthorityAirgapAuditQueryResult(page, next));
+ }
+}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Bootstrap/LdapBootstrapAuditDocument.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Bootstrap/LdapBootstrapAuditDocument.cs
deleted file mode 100644
index 6a358d6af..000000000
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Bootstrap/LdapBootstrapAuditDocument.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace StellaOps.Authority.Plugin.Ldap.Bootstrap;
-
-internal sealed class LdapBootstrapAuditDocument
-{
- [BsonId]
- public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
-
- [BsonElement("plugin")]
- public string Plugin { get; set; } = string.Empty;
-
- [BsonElement("username")]
- public string Username { get; set; } = string.Empty;
-
- [BsonElement("dn")]
- public string DistinguishedName { get; set; } = string.Empty;
-
- [BsonElement("operation")]
- public string Operation { get; set; } = string.Empty;
-
- [BsonElement("secretHash")]
- [BsonIgnoreIfNull]
- public string? SecretHash { get; set; }
-
- [BsonElement("timestamp")]
- public DateTimeOffset Timestamp { get; set; }
-
- [BsonElement("metadata")]
- public Dictionary Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
-}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Claims/InMemoryLdapClaimsCache.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Claims/InMemoryLdapClaimsCache.cs
new file mode 100644
index 000000000..7f3ce9c7e
--- /dev/null
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Claims/InMemoryLdapClaimsCache.cs
@@ -0,0 +1,74 @@
+using System.Collections.Concurrent;
+
+namespace StellaOps.Authority.Plugin.Ldap.Claims;
+
+internal sealed class InMemoryLdapClaimsCache : ILdapClaimsCache
+{
+ private readonly string pluginName;
+ private readonly LdapClaimsCacheOptions options;
+ private readonly TimeProvider timeProvider;
+ private readonly ConcurrentDictionary entries = new(StringComparer.OrdinalIgnoreCase);
+ private readonly TimeSpan entryLifetime;
+
+ public InMemoryLdapClaimsCache(string pluginName, LdapClaimsCacheOptions options, TimeProvider timeProvider)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
+ ArgumentNullException.ThrowIfNull(options);
+ this.pluginName = pluginName;
+ this.options = options;
+ this.timeProvider = timeProvider ?? TimeProvider.System;
+ entryLifetime = TimeSpan.FromSeconds(options.TtlSeconds);
+ }
+
+ public ValueTask GetAsync(string subjectId, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
+
+ var now = timeProvider.GetUtcNow();
+ if (entries.TryGetValue(BuildKey(subjectId), out var entry))
+ {
+ if (entry.ExpiresAt <= now)
+ {
+ entries.TryRemove(BuildKey(subjectId), out _);
+ return ValueTask.FromResult(null);
+ }
+
+ return ValueTask.FromResult(entry.Claims);
+ }
+
+ return ValueTask.FromResult(null);
+ }
+
+ public ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
+ ArgumentNullException.ThrowIfNull(claims);
+
+ if (options.MaxEntries > 0)
+ {
+ EnforceCapacity(options.MaxEntries);
+ }
+
+ var expiresAt = timeProvider.GetUtcNow() + entryLifetime;
+ entries[BuildKey(subjectId)] = new LdapClaimsCacheEntry(claims, expiresAt);
+ return ValueTask.CompletedTask;
+ }
+
+ private void EnforceCapacity(int maxEntries)
+ {
+ while (entries.Count >= maxEntries)
+ {
+ var oldest = entries.OrderBy(kv => kv.Value.ExpiresAt).FirstOrDefault();
+ if (oldest.Key is null)
+ {
+ break;
+ }
+
+ entries.TryRemove(oldest.Key, out _);
+ }
+ }
+
+ private string BuildKey(string subjectId) => $"{pluginName}:{subjectId}".ToLowerInvariant();
+
+ private sealed record LdapClaimsCacheEntry(LdapCachedClaims Claims, DateTimeOffset ExpiresAt);
+}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Claims/MongoLdapClaimsCache.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Claims/MongoLdapClaimsCache.cs
deleted file mode 100644
index 941ddbfb7..000000000
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Claims/MongoLdapClaimsCache.cs
+++ /dev/null
@@ -1,181 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-using MongoDB.Driver;
-
-namespace StellaOps.Authority.Plugin.Ldap.Claims;
-
-internal sealed class MongoLdapClaimsCache : ILdapClaimsCache
-{
- private readonly string pluginName;
- private readonly IMongoCollection collection;
- private readonly LdapClaimsCacheOptions options;
- private readonly TimeProvider timeProvider;
- private readonly ILogger logger;
- private readonly TimeSpan entryLifetime;
-
- public MongoLdapClaimsCache(
- string pluginName,
- IMongoDatabase database,
- LdapClaimsCacheOptions cacheOptions,
- TimeProvider timeProvider,
- ILogger logger)
- {
- ArgumentNullException.ThrowIfNull(pluginName);
- ArgumentNullException.ThrowIfNull(database);
- ArgumentNullException.ThrowIfNull(cacheOptions);
- this.pluginName = pluginName;
- options = cacheOptions;
- this.timeProvider = timeProvider ?? TimeProvider.System;
- this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
- entryLifetime = TimeSpan.FromSeconds(cacheOptions.TtlSeconds);
- var collectionName = cacheOptions.ResolveCollectionName(pluginName);
- collection = database.GetCollection(collectionName);
- EnsureIndexes();
- }
-
- public async ValueTask GetAsync(string subjectId, CancellationToken cancellationToken)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
-
- var document = await collection
- .Find(doc => doc.Id == BuildDocumentId(subjectId))
- .FirstOrDefaultAsync(cancellationToken)
- .ConfigureAwait(false);
-
- if (document is null)
- {
- return null;
- }
-
- if (document.ExpiresAt <= timeProvider.GetUtcNow())
- {
- await collection.DeleteOneAsync(doc => doc.Id == document.Id, cancellationToken).ConfigureAwait(false);
- return null;
- }
-
- IReadOnlyList roles = document.Roles is { Count: > 0 }
- ? document.Roles.AsReadOnly()
- : Array.Empty();
-
- var attributes = document.Attributes is { Count: > 0 }
- ? new Dictionary(document.Attributes, StringComparer.OrdinalIgnoreCase)
- : new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- return new LdapCachedClaims(roles, attributes);
- }
-
- public async ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
- ArgumentNullException.ThrowIfNull(claims);
-
- if (options.MaxEntries > 0)
- {
- await EnforceCapacityAsync(options.MaxEntries, cancellationToken).ConfigureAwait(false);
- }
-
- var now = timeProvider.GetUtcNow();
- var document = new LdapClaimsCacheDocument
- {
- Id = BuildDocumentId(subjectId),
- Plugin = pluginName,
- SubjectId = subjectId,
- CachedAt = now,
- ExpiresAt = now + entryLifetime,
- Roles = claims.Roles?.ToList() ?? new List(),
- Attributes = claims.Attributes?.ToDictionary(
- pair => pair.Key,
- pair => pair.Value,
- StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase)
- };
-
- await collection.ReplaceOneAsync(
- existing => existing.Id == document.Id,
- document,
- new ReplaceOptions { IsUpsert = true },
- cancellationToken).ConfigureAwait(false);
- }
-
- private string BuildDocumentId(string subjectId)
- => $"{pluginName}:{subjectId}".ToLowerInvariant();
-
- private async Task EnforceCapacityAsync(int maxEntries, CancellationToken cancellationToken)
- {
- var total = await collection.CountDocumentsAsync(
- Builders.Filter.Empty,
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- if (total < maxEntries)
- {
- return;
- }
-
- var surplus = (int)(total - maxEntries + 1);
- var ids = await collection
- .Find(Builders.Filter.Empty)
- .SortBy(doc => doc.CachedAt)
- .Limit(surplus)
- .Project(doc => doc.Id)
- .ToListAsync(cancellationToken)
- .ConfigureAwait(false);
-
- if (ids.Count == 0)
- {
- return;
- }
-
- var deleteFilter = Builders.Filter.In(doc => doc.Id, ids);
- await collection.DeleteManyAsync(deleteFilter, cancellationToken).ConfigureAwait(false);
- }
-
- private void EnsureIndexes()
- {
- var expiresIndex = Builders.IndexKeys.Ascending(doc => doc.ExpiresAt);
- var indexModel = new CreateIndexModel(
- expiresIndex,
- new CreateIndexOptions
- {
- Name = "idx_expires_at",
- ExpireAfter = TimeSpan.Zero
- });
-
- try
- {
- collection.Indexes.CreateOne(indexModel);
- }
- catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
- {
- logger.LogDebug(ex, "LDAP claims cache index already exists for plugin {Plugin}.", pluginName);
- }
- }
-}
-
-internal sealed class LdapClaimsCacheDocument
-{
- [BsonId]
- public string Id { get; set; } = string.Empty;
-
- [BsonElement("plugin")]
- public string Plugin { get; set; } = string.Empty;
-
- [BsonElement("subjectId")]
- public string SubjectId { get; set; } = string.Empty;
-
- [BsonElement("roles")]
- public List Roles { get; set; } = new();
-
- [BsonElement("attributes")]
- public Dictionary Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
-
- [BsonElement("cachedAt")]
- public DateTimeOffset CachedAt { get; set; }
-
- [BsonElement("expiresAt")]
- public DateTimeOffset ExpiresAt { get; set; }
-}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/ClientProvisioning/LdapClientProvisioningStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/ClientProvisioning/LdapClientProvisioningStore.cs
index d77ba285c..d9bb4d8f3 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/ClientProvisioning/LdapClientProvisioningStore.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/ClientProvisioning/LdapClientProvisioningStore.cs
@@ -5,8 +5,6 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
@@ -32,7 +30,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
private readonly IAuthorityRevocationStore revocationStore;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor optionsMonitor;
- private readonly IMongoDatabase mongoDatabase;
+ private readonly IAuthorityAirgapAuditStore auditStore;
private readonly TimeProvider clock;
private readonly ILogger logger;
@@ -42,7 +40,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
IAuthorityRevocationStore revocationStore,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor optionsMonitor,
- IMongoDatabase mongoDatabase,
+ IAuthorityAirgapAuditStore auditStore,
TimeProvider clock,
ILogger logger)
{
@@ -51,7 +49,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
- this.mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase));
+ this.auditStore = auditStore ?? throw new ArgumentNullException(nameof(auditStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -198,26 +196,35 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
- var collection = mongoDatabase.GetCollection(collectionName);
-
- var record = new LdapClientProvisioningAuditDocument
+ var properties = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
- Plugin = pluginName,
- ClientId = document.ClientId,
- DistinguishedName = BuildDistinguishedName(document.ClientId, options),
- Operation = operation,
- SecretHash = document.SecretHash,
- Tenant = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
- Project = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
- Timestamp = clock.GetUtcNow(),
- Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase)
- {
- ["senderConstraint"] = document.SenderConstraint,
- ["plugin"] = pluginName
- }
+ ["senderConstraint"] = document.SenderConstraint,
+ ["plugin"] = pluginName,
+ ["distinguishedName"] = BuildDistinguishedName(document.ClientId, options),
+ ["collection"] = collectionName,
+ ["tenant"] = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
+ ["project"] = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
+ ["secretHash"] = document.SecretHash
};
- await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var auditDocument = new AuthorityAirgapAuditDocument
+ {
+ EventType = $"ldap.client.{operation}",
+ OperatorId = pluginName,
+ ComponentId = collectionName,
+ Outcome = "success",
+ OccurredAt = clock.GetUtcNow(),
+ Properties = properties
+ .Where(kv => !string.IsNullOrWhiteSpace(kv.Value) || kv.Key is "plugin" or "collection" or "distinguishedName")
+ .Select(kv => new AuthorityAirgapAuditPropertyDocument
+ {
+ Name = kv.Key,
+ Value = kv.Value ?? string.Empty
+ })
+ .ToList()
+ };
+
+ await auditStore.InsertAsync(auditDocument, cancellationToken, session: null).ConfigureAwait(false);
}
private async Task RecordRevocationAsync(string clientId, CancellationToken cancellationToken)
@@ -429,39 +436,3 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
private LdapClientProvisioningOptions GetProvisioningOptions()
=> optionsMonitor.Get(pluginName).ClientProvisioning;
}
-
-internal sealed class LdapClientProvisioningAuditDocument
-{
- [BsonId]
- public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
-
- [BsonElement("plugin")]
- public string Plugin { get; set; } = string.Empty;
-
- [BsonElement("clientId")]
- public string ClientId { get; set; } = string.Empty;
-
- [BsonElement("dn")]
- public string DistinguishedName { get; set; } = string.Empty;
-
- [BsonElement("operation")]
- public string Operation { get; set; } = string.Empty;
-
- [BsonElement("secretHash")]
- [BsonIgnoreIfNull]
- public string? SecretHash { get; set; }
-
- [BsonElement("tenant")]
- [BsonIgnoreIfNull]
- public string? Tenant { get; set; }
-
- [BsonElement("project")]
- [BsonIgnoreIfNull]
- public string? Project { get; set; }
-
- [BsonElement("timestamp")]
- public DateTimeOffset Timestamp { get; set; }
-
- [BsonElement("metadata")]
- public Dictionary Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
-}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Credentials/LdapCredentialStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Credentials/LdapCredentialStore.cs
index acb6a9db4..3f0ff5a35 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Credentials/LdapCredentialStore.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/Credentials/LdapCredentialStore.cs
@@ -6,13 +6,13 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
-using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
+using StellaOps.Authority.Storage.Mongo.Documents;
+using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Ldap.Credentials;
@@ -27,7 +27,7 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
private readonly ILdapConnectionFactory connectionFactory;
private readonly ILogger logger;
private readonly LdapMetrics metrics;
- private readonly IMongoDatabase mongoDatabase;
+ private readonly IAuthorityAirgapAuditStore auditStore;
private readonly TimeProvider timeProvider;
private readonly Func delayAsync;
@@ -37,7 +37,7 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
ILdapConnectionFactory connectionFactory,
ILogger logger,
LdapMetrics metrics,
- IMongoDatabase mongoDatabase,
+ IAuthorityAirgapAuditStore auditStore,
TimeProvider timeProvider,
Func? delayAsync = null)
{
@@ -46,7 +46,7 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
- this.mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase));
+ this.auditStore = auditStore ?? throw new ArgumentNullException(nameof(auditStore));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.delayAsync = delayAsync ?? ((delay, token) => Task.Delay(delay, token));
}
@@ -511,31 +511,35 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
- var collection = mongoDatabase.GetCollection(collectionName);
-
- var document = new LdapBootstrapAuditDocument
+ var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
- Plugin = pluginName,
- Username = NormalizeUsername(registration.Username),
- DistinguishedName = distinguishedName,
- Operation = "upsert",
- SecretHash = string.IsNullOrWhiteSpace(registration.Password)
- ? null
- : AuthoritySecretHasher.ComputeHash(registration.Password!),
- Timestamp = timeProvider.GetUtcNow(),
- Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase)
- {
- ["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
- ["email"] = registration.Email
- }
+ ["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
+ ["email"] = registration.Email,
+ ["dn"] = distinguishedName,
+ ["collection"] = collectionName,
+ ["username"] = NormalizeUsername(registration.Username)
};
foreach (var attribute in registration.Attributes)
{
- document.Metadata[$"attr.{attribute.Key}"] = attribute.Value;
+ metadata[$"attr.{attribute.Key}"] = attribute.Value;
}
- await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var auditDocument = new AuthorityAirgapAuditDocument
+ {
+ EventType = "ldap.bootstrap.upsert",
+ OperatorId = pluginName,
+ ComponentId = collectionName,
+ Outcome = "success",
+ OccurredAt = timeProvider.GetUtcNow(),
+ Properties = metadata.Select(pair => new AuthorityAirgapAuditPropertyDocument
+ {
+ Name = pair.Key,
+ Value = pair.Value ?? string.Empty
+ }).ToList()
+ };
+
+ await auditStore.InsertAsync(auditDocument, cancellationToken, session: null).ConfigureAwait(false);
}
private IReadOnlyDictionary> BuildBootstrapAttributes(
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/LdapPluginRegistrar.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/LdapPluginRegistrar.cs
index 4a1e516ef..972ffb88e 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/LdapPluginRegistrar.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/LdapPluginRegistrar.cs
@@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
-using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Claims;
@@ -51,7 +50,7 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService(),
sp.GetRequiredService>(),
sp.GetRequiredService(),
- sp.GetRequiredService(),
+ sp.GetRequiredService(),
ResolveTimeProvider(sp)));
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
@@ -60,7 +59,7 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService(),
sp.GetRequiredService(),
sp.GetRequiredService>(),
- sp.GetRequiredService(),
+ sp.GetRequiredService(),
sp.GetRequiredService(),
sp.GetRequiredService>()));
@@ -75,12 +74,10 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
return DisabledLdapClaimsCache.Instance;
}
- return new MongoLdapClaimsCache(
+ return new InMemoryLdapClaimsCache(
pluginName,
- sp.GetRequiredService(),
cacheOptions,
- ResolveTimeProvider(sp),
- sp.GetRequiredService>());
+ ResolveTimeProvider(sp));
});
context.Services.AddScoped(sp => new LdapClaimsEnricher(
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/StellaOps.Authority.Plugin.Ldap.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/StellaOps.Authority.Plugin.Ldap.csproj
index 24eabad9d..6e2e3c4a2 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/StellaOps.Authority.Plugin.Ldap.csproj
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap/StellaOps.Authority.Plugin.Ldap.csproj
@@ -20,6 +20,6 @@
-
+
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs
index 529e6e54c..cc91c283c 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginRegistrarTests.cs
@@ -1,329 +1,323 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Options;
-using Mongo2Go;
-using MongoDB.Driver;
-using StellaOps.Authority.Plugins.Abstractions;
-using StellaOps.Authority.Plugin.Standard;
-using StellaOps.Authority.Plugin.Standard.Bootstrap;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using MongoDB.Driver;
+using StellaOps.Authority.Plugins.Abstractions;
+using StellaOps.Authority.Plugin.Standard;
+using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
-
-namespace StellaOps.Authority.Plugin.Standard.Tests;
-
-public class StandardPluginRegistrarTests
-{
- [Fact]
- public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
- {
- using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- var database = client.GetDatabase("registrar-tests");
-
- var configuration = new ConfigurationBuilder()
- .AddInMemoryCollection(new Dictionary
- {
- ["passwordPolicy:minimumLength"] = "8",
- ["passwordPolicy:requireDigit"] = "false",
- ["passwordPolicy:requireSymbol"] = "false",
- ["lockout:enabled"] = "false",
- ["passwordHashing:memorySizeInKib"] = "8192",
- ["passwordHashing:iterations"] = "2",
- ["passwordHashing:parallelism"] = "1",
- ["bootstrapUser:username"] = "bootstrap",
- ["bootstrapUser:password"] = "Bootstrap1!",
- ["bootstrapUser:requirePasswordReset"] = "true"
- })
- .Build();
-
- var manifest = new AuthorityPluginManifest(
- "standard",
- "standard",
- true,
- typeof(StandardPluginRegistrar).Assembly.GetName().Name,
- typeof(StandardPluginRegistrar).Assembly.Location,
- new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
- new Dictionary(),
- "standard.yaml");
-
+
+namespace StellaOps.Authority.Plugin.Standard.Tests;
+
+public class StandardPluginRegistrarTests
+{
+ [Fact]
+ public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
+ {
+ var client = new InMemoryMongoClient();
+ var database = client.GetDatabase("registrar-tests");
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["passwordPolicy:minimumLength"] = "8",
+ ["passwordPolicy:requireDigit"] = "false",
+ ["passwordPolicy:requireSymbol"] = "false",
+ ["lockout:enabled"] = "false",
+ ["passwordHashing:memorySizeInKib"] = "8192",
+ ["passwordHashing:iterations"] = "2",
+ ["passwordHashing:parallelism"] = "1",
+ ["bootstrapUser:username"] = "bootstrap",
+ ["bootstrapUser:password"] = "Bootstrap1!",
+ ["bootstrapUser:requirePasswordReset"] = "true"
+ })
+ .Build();
+
+ var manifest = new AuthorityPluginManifest(
+ "standard",
+ "standard",
+ true,
+ typeof(StandardPluginRegistrar).Assembly.GetName().Name,
+ typeof(StandardPluginRegistrar).Assembly.Location,
+ new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
+ new Dictionary(),
+ "standard.yaml");
+
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
-
- var registrar = new StandardPluginRegistrar();
- registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
-
- using var provider = services.BuildServiceProvider();
- var hostedServices = provider.GetServices();
- foreach (var hosted in hostedServices)
- {
- if (hosted is StandardPluginBootstrapper bootstrapper)
- {
- await bootstrapper.StartAsync(CancellationToken.None);
- }
- }
-
- using var scope = provider.CreateScope();
- var plugin = scope.ServiceProvider.GetRequiredService();
- Assert.Equal("standard", plugin.Type);
+
+ var registrar = new StandardPluginRegistrar();
+ registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
+
+ using var provider = services.BuildServiceProvider();
+ var hostedServices = provider.GetServices();
+ foreach (var hosted in hostedServices)
+ {
+ if (hosted is StandardPluginBootstrapper bootstrapper)
+ {
+ await bootstrapper.StartAsync(CancellationToken.None);
+ }
+ }
+
+ using var scope = provider.CreateScope();
+ var plugin = scope.ServiceProvider.GetRequiredService();
+ Assert.Equal("standard", plugin.Type);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
- Assert.False(plugin.Capabilities.SupportsMfa);
- Assert.True(plugin.Capabilities.SupportsClientProvisioning);
-
- var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
- Assert.True(verification.Succeeded);
- Assert.True(verification.User?.RequiresPasswordReset);
- }
-
- [Fact]
- public void Register_LogsWarning_WhenPasswordPolicyWeaker()
- {
- using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- var database = client.GetDatabase("registrar-password-policy");
-
- var configuration = new ConfigurationBuilder()
- .AddInMemoryCollection(new Dictionary
- {
- ["passwordPolicy:minimumLength"] = "6",
- ["passwordPolicy:requireUppercase"] = "false",
- ["passwordPolicy:requireLowercase"] = "false",
- ["passwordPolicy:requireDigit"] = "false",
- ["passwordPolicy:requireSymbol"] = "false"
- })
- .Build();
-
- var manifest = new AuthorityPluginManifest(
- "standard",
- "standard",
- true,
- typeof(StandardPluginRegistrar).Assembly.GetName().Name,
- typeof(StandardPluginRegistrar).Assembly.Location,
- new[] { AuthorityPluginCapabilities.Password },
- new Dictionary(),
- "standard.yaml");
-
+ Assert.False(plugin.Capabilities.SupportsMfa);
+ Assert.True(plugin.Capabilities.SupportsClientProvisioning);
+
+ var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
+ Assert.True(verification.Succeeded);
+ Assert.True(verification.User?.RequiresPasswordReset);
+ }
+
+ [Fact]
+ public void Register_LogsWarning_WhenPasswordPolicyWeaker()
+ {
+ var client = new InMemoryMongoClient();
+ var database = client.GetDatabase("registrar-password-policy");
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["passwordPolicy:minimumLength"] = "6",
+ ["passwordPolicy:requireUppercase"] = "false",
+ ["passwordPolicy:requireLowercase"] = "false",
+ ["passwordPolicy:requireDigit"] = "false",
+ ["passwordPolicy:requireSymbol"] = "false"
+ })
+ .Build();
+
+ var manifest = new AuthorityPluginManifest(
+ "standard",
+ "standard",
+ true,
+ typeof(StandardPluginRegistrar).Assembly.GetName().Name,
+ typeof(StandardPluginRegistrar).Assembly.Location,
+ new[] { AuthorityPluginCapabilities.Password },
+ new Dictionary(),
+ "standard.yaml");
+
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
-
- var registrar = new StandardPluginRegistrar();
- registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
-
- using var provider = services.BuildServiceProvider();
- using var scope = provider.CreateScope();
- _ = scope.ServiceProvider.GetRequiredService();
-
- Assert.Contains(loggerProvider.Entries, entry =>
- entry.Level == LogLevel.Warning &&
- entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
- entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
- }
-
- [Fact]
- public void Register_ForcesPasswordCapability_WhenManifestMissing()
- {
- using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- var database = client.GetDatabase("registrar-capabilities");
-
- var configuration = new ConfigurationBuilder().Build();
- var manifest = new AuthorityPluginManifest(
- "standard",
- "standard",
- true,
- typeof(StandardPluginRegistrar).Assembly.GetName().Name,
- typeof(StandardPluginRegistrar).Assembly.Location,
- Array.Empty(),
- new Dictionary(),
- "standard.yaml");
-
+
+ var registrar = new StandardPluginRegistrar();
+ registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
+
+ using var provider = services.BuildServiceProvider();
+ using var scope = provider.CreateScope();
+ _ = scope.ServiceProvider.GetRequiredService();
+
+ Assert.Contains(loggerProvider.Entries, entry =>
+ entry.Level == LogLevel.Warning &&
+ entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
+ entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void Register_ForcesPasswordCapability_WhenManifestMissing()
+ {
+ var client = new InMemoryMongoClient();
+ var database = client.GetDatabase("registrar-capabilities");
+
+ var configuration = new ConfigurationBuilder().Build();
+ var manifest = new AuthorityPluginManifest(
+ "standard",
+ "standard",
+ true,
+ typeof(StandardPluginRegistrar).Assembly.GetName().Name,
+ typeof(StandardPluginRegistrar).Assembly.Location,
+ Array.Empty(),
+ new Dictionary(),
+ "standard.yaml");
+
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
-
- var registrar = new StandardPluginRegistrar();
- registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
-
- using var provider = services.BuildServiceProvider();
- using var scope = provider.CreateScope();
- var plugin = scope.ServiceProvider.GetRequiredService();
-
- Assert.True(plugin.Capabilities.SupportsPassword);
- Assert.True(plugin.Capabilities.SupportsBootstrap);
- Assert.True(plugin.Capabilities.SupportsClientProvisioning);
- }
-
- [Fact]
- public void Register_Throws_WhenBootstrapConfigurationIncomplete()
- {
- using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- var database = client.GetDatabase("registrar-bootstrap-validation");
-
- var configuration = new ConfigurationBuilder()
- .AddInMemoryCollection(new Dictionary
- {
- ["bootstrapUser:username"] = "bootstrap"
- })
- .Build();
-
- var manifest = new AuthorityPluginManifest(
- "standard",
- "standard",
- true,
- typeof(StandardPluginRegistrar).Assembly.GetName().Name,
- typeof(StandardPluginRegistrar).Assembly.Location,
- new[] { AuthorityPluginCapabilities.Password },
- new Dictionary(),
- "standard.yaml");
-
- var pluginContext = new AuthorityPluginContext(manifest, configuration);
- var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
- registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
-
- using var provider = services.BuildServiceProvider();
- using var scope = provider.CreateScope();
- Assert.Throws(() => scope.ServiceProvider.GetRequiredService());
- }
-
- [Fact]
- public void Register_NormalizesTokenSigningKeyDirectory()
- {
- using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
- var database = client.GetDatabase("registrar-token-signing");
-
- var configuration = new ConfigurationBuilder()
- .AddInMemoryCollection(new Dictionary
- {
- ["tokenSigning:keyDirectory"] = "../keys"
- })
- .Build();
-
- var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(configDir);
-
- try
- {
- var configPath = Path.Combine(configDir, "standard.yaml");
- var manifest = new AuthorityPluginManifest(
- "standard",
- "standard",
- true,
- typeof(StandardPluginRegistrar).Assembly.GetName().Name,
- typeof(StandardPluginRegistrar).Assembly.Location,
- new[] { AuthorityPluginCapabilities.Password },
- new Dictionary(),
- configPath);
-
- var pluginContext = new AuthorityPluginContext(manifest, configuration);
+ registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
+
+ using var provider = services.BuildServiceProvider();
+ using var scope = provider.CreateScope();
+ var plugin = scope.ServiceProvider.GetRequiredService();
+
+ Assert.True(plugin.Capabilities.SupportsPassword);
+ Assert.True(plugin.Capabilities.SupportsBootstrap);
+ Assert.True(plugin.Capabilities.SupportsClientProvisioning);
+ }
+
+ [Fact]
+ public void Register_Throws_WhenBootstrapConfigurationIncomplete()
+ {
+ var client = new InMemoryMongoClient();
+ var database = client.GetDatabase("registrar-bootstrap-validation");
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["bootstrapUser:username"] = "bootstrap"
+ })
+ .Build();
+
+ var manifest = new AuthorityPluginManifest(
+ "standard",
+ "standard",
+ true,
+ typeof(StandardPluginRegistrar).Assembly.GetName().Name,
+ typeof(StandardPluginRegistrar).Assembly.Location,
+ new[] { AuthorityPluginCapabilities.Password },
+ new Dictionary(),
+ "standard.yaml");
+
+ var pluginContext = new AuthorityPluginContext(manifest, configuration);
+ var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
+
+ var registrar = new StandardPluginRegistrar();
+ registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
+
+ using var provider = services.BuildServiceProvider();
+ using var scope = provider.CreateScope();
+ Assert.Throws(() => scope.ServiceProvider.GetRequiredService());
+ }
+
+ [Fact]
+ public void Register_NormalizesTokenSigningKeyDirectory()
+ {
+ var client = new InMemoryMongoClient();
+ var database = client.GetDatabase("registrar-token-signing");
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["tokenSigning:keyDirectory"] = "../keys"
+ })
+ .Build();
+
+ var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(configDir);
+
+ try
+ {
+ var configPath = Path.Combine(configDir, "standard.yaml");
+ var manifest = new AuthorityPluginManifest(
+ "standard",
+ "standard",
+ true,
+ typeof(StandardPluginRegistrar).Assembly.GetName().Name,
+ typeof(StandardPluginRegistrar).Assembly.Location,
+ new[] { AuthorityPluginCapabilities.Password },
+ new Dictionary(),
+ configPath);
+
+ var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
services.AddSingleton(TimeProvider.System);
-
- var registrar = new StandardPluginRegistrar();
- registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
-
- using var provider = services.BuildServiceProvider();
- var optionsMonitor = provider.GetRequiredService>();
- var options = optionsMonitor.Get("standard");
-
- var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
- Assert.Equal(expected, options.TokenSigning.KeyDirectory);
- }
- finally
- {
- if (Directory.Exists(configDir))
- {
- Directory.Delete(configDir, recursive: true);
- }
- }
- }
-}
-
-internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);
-
-internal sealed class CapturingLoggerProvider : ILoggerProvider
-{
- public List Entries { get; } = new();
-
- public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
-
- public void Dispose()
- {
- }
-
- private sealed class CapturingLogger : ILogger
- {
- private readonly string category;
- private readonly List entries;
-
- public CapturingLogger(string category, List entries)
- {
- this.category = category;
- this.entries = entries;
- }
-
- public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
-
- public bool IsEnabled(LogLevel logLevel) => true;
-
- public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
- {
- entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
- }
-
- private sealed class NullScope : IDisposable
- {
- public static readonly NullScope Instance = new();
-
- public void Dispose()
- {
- }
- }
- }
-}
-
-internal sealed class StubRevocationStore : IAuthorityRevocationStore
-{
- public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
- => ValueTask.CompletedTask;
-
- public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
- => ValueTask.FromResult(false);
-
- public ValueTask> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
- => ValueTask.FromResult>(Array.Empty());
-}
-
+
+ var registrar = new StandardPluginRegistrar();
+ registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
+
+ using var provider = services.BuildServiceProvider();
+ var optionsMonitor = provider.GetRequiredService>();
+ var options = optionsMonitor.Get("standard");
+
+ var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
+ Assert.Equal(expected, options.TokenSigning.KeyDirectory);
+ }
+ finally
+ {
+ if (Directory.Exists(configDir))
+ {
+ Directory.Delete(configDir, recursive: true);
+ }
+ }
+ }
+}
+
+internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);
+
+internal sealed class CapturingLoggerProvider : ILoggerProvider
+{
+ public List Entries { get; } = new();
+
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
+
+ public void Dispose()
+ {
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ private readonly string category;
+ private readonly List entries;
+
+ public CapturingLogger(string category, List entries)
+ {
+ this.category = category;
+ this.entries = entries;
+ }
+
+ public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
+ }
+
+ private sealed class NullScope : IDisposable
+ {
+ public static readonly NullScope Instance = new();
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
+
+internal sealed class StubRevocationStore : IAuthorityRevocationStore
+{
+ public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ => ValueTask.CompletedTask;
+
+ public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ => ValueTask.FromResult(false);
+
+ public ValueTask> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ => ValueTask.FromResult>(Array.Empty());
+}
+
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
- {
- clients.TryGetValue(clientId, out var document);
- return ValueTask.FromResult(document);
- }
-
- public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
- {
- clients[document.ClientId] = document;
- return ValueTask.CompletedTask;
- }
-
+ {
+ clients.TryGetValue(clientId, out var document);
+ return ValueTask.FromResult(document);
+ }
+
+ public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ clients[document.ClientId] = document;
+ return ValueTask.CompletedTask;
+ }
+
public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs
index 2065a4607..1bdcf8620 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardUserCredentialStoreTests.cs
@@ -5,7 +5,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
-using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
@@ -17,7 +16,6 @@ namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardUserCredentialStoreTests : IAsyncLifetime
{
- private readonly MongoDbRunner runner;
private readonly IMongoDatabase database;
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
@@ -25,8 +23,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
public StandardUserCredentialStoreTests()
{
- runner = MongoDbRunner.Start(singleNodeReplSet: true);
- var client = new MongoClient(runner.ConnectionString);
+ var client = new InMemoryMongoClient();
database = client.GetDatabase("authority-tests");
options = new StandardPluginOptions
{
@@ -203,7 +200,6 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
public Task DisposeAsync()
{
- runner.Dispose();
return Task.CompletedTask;
}
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityDocuments.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityDocuments.cs
index e3f8fa469..0642f4b1f 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityDocuments.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/AuthorityDocuments.cs
@@ -12,9 +12,33 @@ public sealed class AuthorityBootstrapInviteDocument
public string? Target { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset IssuedAt { get; set; }
+ public string? IssuedBy { get; set; }
+ public DateTimeOffset? ReservedUntil { get; set; }
+ public string? ReservedBy { get; set; }
public bool Consumed { get; set; }
+ public string Status { get; set; } = AuthorityBootstrapInviteStatuses.Pending;
+ public Dictionary? Metadata { get; set; }
}
+public static class AuthorityBootstrapInviteStatuses
+{
+ public const string Pending = "pending";
+ public const string Reserved = "reserved";
+ public const string Consumed = "consumed";
+ public const string Expired = "expired";
+}
+
+public enum BootstrapInviteReservationStatus
+{
+ NotFound,
+ Reserved,
+ Expired,
+ AlreadyUsed
+}
+
+public sealed record BootstrapInviteReservationResult(BootstrapInviteReservationStatus Status, AuthorityBootstrapInviteDocument? Invite);
+
///
/// Represents a service account document.
///
@@ -28,7 +52,7 @@ public sealed class AuthorityServiceAccountDocument
public bool Enabled { get; set; } = true;
public List AllowedScopes { get; set; } = new();
public List AuthorizedClients { get; set; } = new();
- public Dictionary Attributes { get; set; } = new();
+ public Dictionary> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -59,6 +83,7 @@ public sealed class AuthorityClientDocument
public List CertificateBindings { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
+ public bool Disabled { get; set; }
}
///
@@ -72,11 +97,14 @@ public sealed class AuthorityRevocationDocument
public string SubjectId { get; set; } = string.Empty;
public string? ClientId { get; set; }
public string? TokenId { get; set; }
+ public string? TokenType { get; set; }
public string Reason { get; set; } = string.Empty;
public string? ReasonDescription { get; set; }
public DateTimeOffset RevokedAt { get; set; }
- public DateTimeOffset EffectiveAt { get; set; }
+ public DateTimeOffset? EffectiveAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
+ public List? Scopes { get; set; }
+ public string? Fingerprint { get; set; }
public Dictionary Metadata { get; set; } = new();
}
@@ -86,13 +114,20 @@ public sealed class AuthorityRevocationDocument
public sealed class AuthorityLoginAttemptDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string? CorrelationId { get; set; }
public string? SubjectId { get; set; }
+ public string? Username { get; set; }
public string? ClientId { get; set; }
+ public string? Plugin { get; set; }
public string EventType { get; set; } = string.Empty;
public string Outcome { get; set; } = string.Empty;
+ public bool Successful { get; set; }
public string? Reason { get; set; }
+ public string? RemoteAddress { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
+ public string? Tenant { get; set; }
+ public List Scopes { get; set; } = new();
public DateTimeOffset OccurredAt { get; set; }
public List Properties { get; set; } = new();
}
@@ -105,6 +140,7 @@ public sealed class AuthorityLoginAttemptPropertyDocument
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool Sensitive { get; set; }
+ public string Classification { get; set; } = "none";
}
///
@@ -117,12 +153,37 @@ public sealed class AuthorityTokenDocument
public string? SubjectId { get; set; }
public string? ClientId { get; set; }
public string TokenType { get; set; } = string.Empty;
+ public string Type
+ {
+ get => TokenType;
+ set => TokenType = value;
+ }
public string? ReferenceId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset? RedeemedAt { get; set; }
public string? Payload { get; set; }
+ public List Scope { get; set; } = new();
+ public string Status { get; set; } = "valid";
+ public string? Tenant { get; set; }
+ public string? Project { get; set; }
+ public string? SenderConstraint { get; set; }
+ public string? SenderNonce { get; set; }
+ public string? SenderCertificateHex { get; set; }
+ public string? SenderKeyThumbprint { get; set; }
+ public string? ServiceAccountId { get; set; }
+ public string? TokenKind { get; set; }
+ public string? VulnerabilityEnvironment { get; set; }
+ public string? VulnerabilityOwner { get; set; }
+ public string? VulnerabilityBusinessTier { get; set; }
+ public List ActorChain { get; set; } = new();
+ public string? IncidentReason { get; set; }
+ public List Devices { get; set; } = new();
public Dictionary Properties { get; set; } = new();
+ public DateTimeOffset? RevokedAt { get; set; }
+ public string? RevokedReason { get; set; }
+ public string? RevokedReasonDescription { get; set; }
+ public IReadOnlyDictionary? RevokedMetadata { get; set; }
}
///
@@ -147,11 +208,19 @@ public sealed class AuthorityRefreshTokenDocument
public sealed class AuthorityAirgapAuditDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string? Tenant { get; set; }
+ public string? SubjectId { get; set; }
+ public string? Username { get; set; }
+ public string? DisplayName { get; set; }
+ public string? ClientId { get; set; }
+ public string? BundleId { get; set; }
+ public string? Status { get; set; }
public string EventType { get; set; } = string.Empty;
public string? OperatorId { get; set; }
public string? ComponentId { get; set; }
public string Outcome { get; set; } = string.Empty;
public string? Reason { get; set; }
+ public string? TraceId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public List Properties { get; set; } = new();
}
@@ -165,6 +234,41 @@ public sealed class AuthorityAirgapAuditPropertyDocument
public string Value { get; set; } = string.Empty;
}
+///
+/// Query parameters for airgap audit search.
+///
+public sealed class AuthorityAirgapAuditQuery
+{
+ public string? Tenant { get; set; }
+ public string? BundleId { get; set; }
+ public string? Status { get; set; }
+ public string? TraceId { get; set; }
+ public string? AfterId { get; set; }
+ public int Limit { get; set; } = 50;
+}
+
+public sealed class AuthorityAirgapAuditQueryResult
+{
+ public AuthorityAirgapAuditQueryResult(IReadOnlyList items, string? nextCursor)
+ {
+ Items = items ?? throw new ArgumentNullException(nameof(items));
+ NextCursor = nextCursor;
+ }
+
+ public IReadOnlyList Items { get; }
+ public string? NextCursor { get; }
+}
+
+///
+/// Tracks the last exported revocation bundle metadata.
+///
+public sealed class AuthorityRevocationExportStateDocument
+{
+ public long Sequence { get; set; }
+ public string? BundleId { get; set; }
+ public DateTimeOffset? IssuedAt { get; set; }
+}
+
///
/// Represents a certificate binding for client authentication.
///
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/TokenUsage.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/TokenUsage.cs
new file mode 100644
index 000000000..e95c50455
--- /dev/null
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Documents/TokenUsage.cs
@@ -0,0 +1,17 @@
+namespace StellaOps.Authority.Storage.Mongo.Documents;
+
+///
+/// Result status for token usage recording.
+///
+public enum TokenUsageUpdateStatus
+{
+ Recorded,
+ MissingMetadata,
+ NotFound,
+ SuspectedReplay
+}
+
+///
+/// Represents the outcome of recording token usage.
+///
+public sealed record TokenUsageUpdateResult(TokenUsageUpdateStatus Status, string? RemoteAddress, string? UserAgent);
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Extensions/ServiceCollectionExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Extensions/ServiceCollectionExtensions.cs
index b79f31b7c..885686176 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Extensions/ServiceCollectionExtensions.cs
@@ -62,5 +62,6 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
}
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Sessions/IClientSessionHandle.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Sessions/IClientSessionHandle.cs
index 5fd93e917..20ff69d0b 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Sessions/IClientSessionHandle.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Sessions/IClientSessionHandle.cs
@@ -13,6 +13,7 @@ public interface IClientSessionHandle : IDisposable
public interface IAuthorityMongoSessionAccessor
{
IClientSessionHandle? CurrentSession { get; }
+ ValueTask GetSessionAsync(CancellationToken cancellationToken);
}
///
@@ -21,4 +22,7 @@ public interface IAuthorityMongoSessionAccessor
public sealed class NullAuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public IClientSessionHandle? CurrentSession => null;
+
+ public ValueTask GetSessionAsync(CancellationToken cancellationToken)
+ => ValueTask.FromResult(null);
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/IAuthorityStores.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/IAuthorityStores.cs
index 815f003c0..2b3f32067 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/IAuthorityStores.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/IAuthorityStores.cs
@@ -9,8 +9,10 @@ namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityBootstrapInviteStore
{
ValueTask FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
- ValueTask InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
- ValueTask ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
@@ -21,6 +23,7 @@ public interface IAuthorityServiceAccountStore
{
ValueTask FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
+ ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
@@ -62,9 +65,16 @@ public interface IAuthorityTokenStore
ValueTask FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
@@ -87,4 +97,14 @@ public interface IAuthorityAirgapAuditStore
{
ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null);
+}
+
+///
+/// Tracks revocation export state to enforce monotonic bundle sequencing.
+///
+public interface IAuthorityRevocationExportStateStore
+{
+ ValueTask GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
+ ValueTask UpdateAsync(long expectedSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/InMemoryStores.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/InMemoryStores.cs
index abf81bc5f..69d2e5b22 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/InMemoryStores.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo/Stores/InMemoryStores.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Threading;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
@@ -17,19 +18,71 @@ public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStor
return ValueTask.FromResult(doc);
}
- public ValueTask InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ public ValueTask CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
+ document.CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt;
+ document.IssuedAt = document.IssuedAt == default ? document.CreatedAt : document.IssuedAt;
+ document.Status = AuthorityBootstrapInviteStatuses.Pending;
_invites[document.Token] = document;
- return ValueTask.CompletedTask;
+ return ValueTask.FromResult(document);
}
- public ValueTask ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ public ValueTask TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ if (!_invites.TryGetValue(token, out var doc))
+ {
+ return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
+ }
+
+ if (!string.Equals(doc.Type, expectedType, StringComparison.OrdinalIgnoreCase))
+ {
+ return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
+ }
+
+ if (doc.ExpiresAt <= now)
+ {
+ doc.Status = AuthorityBootstrapInviteStatuses.Expired;
+ return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, doc));
+ }
+
+ if (doc.Consumed || string.Equals(doc.Status, AuthorityBootstrapInviteStatuses.Consumed, StringComparison.OrdinalIgnoreCase))
+ {
+ return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.AlreadyUsed, doc));
+ }
+
+ doc.Status = AuthorityBootstrapInviteStatuses.Reserved;
+ doc.ReservedBy = reservedBy;
+ doc.ReservedUntil = now.AddMinutes(15);
+ _invites[token] = doc;
+ return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, doc));
+ }
+
+ public ValueTask ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ if (_invites.TryGetValue(token, out var doc) && string.Equals(doc.Status, AuthorityBootstrapInviteStatuses.Reserved, StringComparison.OrdinalIgnoreCase))
+ {
+ doc.Status = AuthorityBootstrapInviteStatuses.Pending;
+ doc.ReservedBy = null;
+ doc.ReservedUntil = null;
+ _invites[token] = doc;
+ return ValueTask.FromResult(true);
+ }
+
+ return ValueTask.FromResult(false);
+ }
+
+ public ValueTask MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (_invites.TryGetValue(token, out var doc))
{
doc.Consumed = true;
+ doc.Status = AuthorityBootstrapInviteStatuses.Consumed;
+ doc.ReservedUntil = consumedAt;
+ doc.ReservedBy = consumedBy;
+ _invites[token] = doc;
return ValueTask.FromResult(true);
}
+
return ValueTask.FromResult(false);
}
@@ -41,11 +94,13 @@ public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStor
foreach (var item in expired)
{
+ item.Status = AuthorityBootstrapInviteStatuses.Expired;
_invites.TryRemove(item.Token, out _);
}
return ValueTask.FromResult>(expired);
}
+
}
///
@@ -69,6 +124,12 @@ public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
return ValueTask.FromResult>(results);
}
+ public ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
+ {
+ var results = _accounts.Values.Where(a => string.Equals(a.Tenant, tenant, StringComparison.Ordinal)).ToList();
+ return ValueTask.FromResult>(results);
+ }
+
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
document.UpdatedAt = DateTimeOffset.UtcNow;
@@ -191,35 +252,138 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
return ValueTask.FromResult>(results);
}
+ public ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ var normalizedScope = scope?.Trim();
+ var results = _tokens.Values
+ .Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
+ .Where(t => issuedAfter is null || t.CreatedAt >= issuedAfter.Value)
+ .Where(t => t.Scope.Any(s => string.Equals(s, normalizedScope, StringComparison.Ordinal)))
+ .OrderByDescending(t => t.CreatedAt)
+ .Take(limit)
+ .ToList();
+
+ return ValueTask.FromResult>(results);
+ }
+
+ public ValueTask> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ var results = _tokens.Values
+ .Where(t => string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
+ .Where(t => string.IsNullOrWhiteSpace(tenant) || string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
+ .OrderBy(t => t.TokenId, StringComparer.Ordinal)
+ .ToList();
+
+ return ValueTask.FromResult>(results);
+ }
+
+ public ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var count = _tokens.Values
+ .Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
+ .Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
+ .Where(t => !string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
+ .Where(t => t.ExpiresAt is null || t.ExpiresAt > now)
+ .LongCount();
+
+ return ValueTask.FromResult(count);
+ }
+
+ public ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var items = _tokens.Values
+ .Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
+ .Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
+ .Where(t => !string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
+ .Where(t => t.ExpiresAt is null || t.ExpiresAt > now)
+ .ToList();
+
+ return ValueTask.FromResult>(items);
+ }
+
+ public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ => UpsertAsync(document, cancellationToken, session);
+
public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_tokens[document.TokenId] = document;
return ValueTask.CompletedTask;
}
+ public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ if (_tokens.TryGetValue(tokenId, out var doc))
+ {
+ doc.Status = status;
+ doc.RevokedAt = revokedAt;
+ doc.RevokedReason = reason;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
public ValueTask RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
- return ValueTask.FromResult(_tokens.TryRemove(tokenId, out _));
+ if (_tokens.TryGetValue(tokenId, out var doc))
+ {
+ doc.Status = "revoked";
+ doc.RevokedAt = DateTimeOffset.UtcNow;
+ return ValueTask.FromResult(true);
+ }
+
+ return ValueTask.FromResult(false);
}
public ValueTask RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
- var toRemove = _tokens.Where(kv => kv.Value.SubjectId == subjectId).Select(kv => kv.Key).ToList();
- foreach (var key in toRemove)
+ var now = DateTimeOffset.UtcNow;
+ var revoked = 0;
+ foreach (var token in _tokens.Values.Where(t => t.SubjectId == subjectId))
{
- _tokens.TryRemove(key, out _);
+ token.Status = "revoked";
+ token.RevokedAt = now;
+ revoked++;
}
- return ValueTask.FromResult(toRemove.Count);
+
+ return ValueTask.FromResult(revoked);
}
public ValueTask RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
- var toRemove = _tokens.Where(kv => kv.Value.ClientId == clientId).Select(kv => kv.Key).ToList();
- foreach (var key in toRemove)
+ var now = DateTimeOffset.UtcNow;
+ var revoked = 0;
+ foreach (var token in _tokens.Values.Where(t => t.ClientId == clientId))
{
- _tokens.TryRemove(key, out _);
+ token.Status = "revoked";
+ token.RevokedAt = now;
+ revoked++;
}
- return ValueTask.FromResult(toRemove.Count);
+
+ return ValueTask.FromResult(revoked);
+ }
+
+ public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ if (!_tokens.TryGetValue(tokenId, out var document))
+ {
+ return ValueTask.FromResult(new TokenUsageUpdateResult(TokenUsageUpdateStatus.NotFound, remoteAddress, userAgent));
+ }
+
+ if (string.IsNullOrWhiteSpace(remoteAddress) && string.IsNullOrWhiteSpace(userAgent))
+ {
+ return ValueTask.FromResult(new TokenUsageUpdateResult(TokenUsageUpdateStatus.MissingMetadata, remoteAddress, userAgent));
+ }
+
+ var fingerprint = $"{remoteAddress}|{userAgent}";
+ if (document.Devices.All(d => d != fingerprint))
+ {
+ document.Devices.Add(fingerprint);
+ }
+
+ var status = document.Devices.Count > 1 ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded;
+ return ValueTask.FromResult(new TokenUsageUpdateResult(status, remoteAddress, userAgent));
}
}
@@ -291,4 +455,93 @@ public sealed class InMemoryAirgapAuditStore : IAuthorityAirgapAuditStore
.ToList();
return ValueTask.FromResult>(results);
}
+
+ public ValueTask QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ var filtered = _entries.AsEnumerable();
+
+ if (!string.IsNullOrWhiteSpace(query.Tenant))
+ {
+ filtered = filtered.Where(e => string.Equals(e.Tenant, query.Tenant, StringComparison.Ordinal));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.BundleId))
+ {
+ filtered = filtered.Where(e => string.Equals(e.BundleId, query.BundleId, StringComparison.Ordinal));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Status))
+ {
+ filtered = filtered.Where(e => string.Equals(e.Status, query.Status, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.TraceId))
+ {
+ filtered = filtered.Where(e => string.Equals(e.TraceId, query.TraceId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ filtered = filtered.OrderByDescending(e => e.OccurredAt).ThenBy(e => e.Id, StringComparer.Ordinal);
+
+ if (!string.IsNullOrWhiteSpace(query.AfterId))
+ {
+ filtered = filtered.SkipWhile(e => !string.Equals(e.Id, query.AfterId, StringComparison.Ordinal)).Skip(1);
+ }
+
+ var take = query.Limit <= 0 ? 50 : query.Limit;
+ var items = filtered.Take(take + 1).ToList();
+ var nextCursor = items.Count > take ? items[^1].Id : null;
+ if (items.Count > take)
+ {
+ items.RemoveAt(items.Count - 1);
+ }
+
+ return ValueTask.FromResult(new AuthorityAirgapAuditQueryResult(items, nextCursor));
+ }
+}
+
+///
+/// In-memory implementation of the revocation export state store.
+///
+public sealed class InMemoryRevocationExportStateStore : IAuthorityRevocationExportStateStore
+{
+ private readonly SemaphoreSlim gate = new(1, 1);
+ private AuthorityRevocationExportStateDocument state = new() { Sequence = 0 };
+
+ public async ValueTask GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return state;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public async ValueTask UpdateAsync(long expectedSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
+ {
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (state.Sequence != expectedSequence)
+ {
+ throw new InvalidOperationException($"Revocation export sequence mismatch. Expected {expectedSequence}, current {state.Sequence}.");
+ }
+
+ state = new AuthorityRevocationExportStateDocument
+ {
+ Sequence = newSequence,
+ BundleId = bundleId,
+ IssuedAt = issuedAt
+ };
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
}
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs
index 5eae0d39a..54fc60ba8 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/AdvisoryAi/AdvisoryAiRemoteInferenceEndpointTests.cs
@@ -1,282 +1,244 @@
-using System;
-using System.Collections.Generic;
-using System.Net;
-using System.Net.Http;
-using System.Net.Http.Json;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.Json.Serialization;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Hosting;
-using System.Net.Http.Headers;
-using Microsoft.Extensions.DependencyInjection;
-using MongoDB.Bson;
-using MongoDB.Driver;
-using StellaOps.Authority.Tests.Infrastructure;
-using StellaOps.Auth.Abstractions;
-using StellaOps.Configuration;
-using Xunit;
-using Microsoft.AspNetCore.TestHost;
-
-namespace StellaOps.Authority.Tests.AdvisoryAi;
-
-public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture
-{
- private readonly AuthorityWebApplicationFactory factory;
-
- public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
- {
- this.factory = factory;
- }
-
- [Fact]
- public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
- {
- using var client = CreateClient(
- configureOptions: options =>
- {
- options.AdvisoryAi.RemoteInference.Enabled = false;
- options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
- options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
- });
-
- var response = await client.PostAsJsonAsync(
- "/advisory-ai/remote-inference/logs",
- CreatePayload(profile: "cloud-openai"));
-
- Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
- var body = await response.Content.ReadFromJsonAsync>();
- Assert.NotNull(body);
- Assert.Equal("remote_inference_disabled", body!["error"]);
- }
-
- [Fact]
- public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
- {
- using var client = CreateClient(
- configureOptions: options =>
- {
- SeedRemoteInferenceEnabled(options);
- SeedTenantConsent(options);
- options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
- options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
- options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
- options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
- });
-
- client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
-
- var response = await client.PostAsJsonAsync(
- "/advisory-ai/remote-inference/logs",
- CreatePayload(profile: "cloud-openai"));
-
- Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
- var body = await response.Content.ReadFromJsonAsync>();
- Assert.NotNull(body);
- Assert.Equal("remote_inference_consent_required", body!["error"]);
- }
-
- [Fact]
- public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
- {
- using var client = CreateClient(
- configureOptions: options =>
- {
- SeedRemoteInferenceEnabled(options);
- SeedTenantConsent(options);
- });
-
- client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
-
- var response = await client.PostAsJsonAsync(
- "/advisory-ai/remote-inference/logs",
- CreatePayload(profile: "other-profile"));
-
- Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- var body = await response.Content.ReadFromJsonAsync>();
- Assert.NotNull(body);
- Assert.Equal("profile_not_allowed", body!["error"]);
- }
-
- [Fact]
- public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
- {
- using var client = CreateClient(
- configureOptions: options =>
- {
- SeedRemoteInferenceEnabled(options);
- SeedTenantConsent(options);
- });
-
- client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
-
- var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
- var collection = database.GetCollection("authority_login_attempts");
- await collection.DeleteManyAsync(FilterDefinition.Empty);
-
- var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan.");
- var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
-
- Assert.Equal(HttpStatusCode.OK, response.StatusCode);
- var body = await response.Content.ReadFromJsonAsync>();
- Assert.NotNull(body);
- Assert.Equal("logged", body!["status"]);
-
- var expectedHash = ComputeSha256(payload.Prompt);
- Assert.Equal(expectedHash, body["prompt_hash"]);
-
- var doc = await collection.Find(Builders.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync();
- Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString);
-
- var properties = ExtractProperties(doc);
- Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
- Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
- Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
- Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
- }
-
- private HttpClient CreateClient(Action? configureOptions = null)
- {
- const string schemeName = "StellaOpsBearer";
-
- var builder = factory.WithWebHostBuilder(hostBuilder =>
- {
- hostBuilder.ConfigureTestServices(services =>
- {
- services.AddAuthentication(options =>
- {
- options.DefaultAuthenticateScheme = schemeName;
- options.DefaultChallengeScheme = schemeName;
- })
- .AddScheme(schemeName, _ => { });
-
- services.PostConfigure(opts =>
- {
- opts.Issuer ??= new Uri("https://authority.test");
- if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString))
- {
- opts.Storage.ConnectionString = factory.ConnectionString;
- }
-
- if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName))
- {
- opts.Storage.DatabaseName = "authority-tests";
- }
-
- opts.AdvisoryAi.RemoteInference.Enabled = true;
- opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
- opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
- opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
-
- opts.Tenants.Clear();
- opts.Tenants.Add(new AuthorityTenantOptions
- {
- Id = "tenant-default",
- DisplayName = "Tenant Default",
- AdvisoryAi =
- {
- RemoteInference =
- {
- ConsentGranted = true,
- ConsentVersion = "2025-10",
- ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"),
- ConsentedBy = "legal@example.com"
- }
- }
- });
-
- configureOptions?.Invoke(opts);
- });
- });
- });
-
- var client = builder.CreateClient();
- client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
- return client;
- }
-
- private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
- {
- options.AdvisoryAi.RemoteInference.Enabled = true;
- options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
- options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
- options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
- }
-
- private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
- {
- if (options.Tenants.Count == 0)
- {
- options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" });
- }
-
- var tenant = options.Tenants[0];
- tenant.Id = "tenant-default";
- tenant.DisplayName = "Tenant Default";
- tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
- tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10";
- tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z");
- tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com";
- }
-
- private static string ComputeSha256(string value)
- {
- var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
- return Convert.ToHexString(hash).ToLowerInvariant();
- }
-
- private static Dictionary ExtractProperties(BsonDocument document)
- {
- var result = new Dictionary(StringComparer.Ordinal);
- if (!document.TryGetValue("properties", out var propertiesValue))
- {
- return result;
- }
-
- foreach (var item in propertiesValue.AsBsonArray)
- {
- if (item is not BsonDocument property)
- {
- continue;
- }
-
- var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null;
- var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null;
-
- if (!string.IsNullOrWhiteSpace(name))
- {
- result[name] = value ?? string.Empty;
- }
- }
-
- return result;
- }
-
- private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.")
- {
- return new RemoteInferencePayload(
- TaskType: "summary",
- Profile: profile,
- ModelId: "gpt-4o-mini",
- Prompt: prompt,
- ContextDigest: "sha256:context",
- OutputHash: "sha256:output",
- TaskId: "task-123",
- Metadata: new Dictionary
- {
- ["channel"] = "cli",
- ["env"] = "test"
- });
- }
-
- private sealed record RemoteInferencePayload(
- [property: JsonPropertyName("taskType")] string TaskType,
- [property: JsonPropertyName("profile")] string Profile,
- [property: JsonPropertyName("modelId")] string ModelId,
- [property: JsonPropertyName("prompt")] string Prompt,
- [property: JsonPropertyName("contextDigest")] string ContextDigest,
- [property: JsonPropertyName("outputHash")] string OutputHash,
- [property: JsonPropertyName("taskId")] string TaskId,
- [property: JsonPropertyName("metadata")] IDictionary Metadata);
-}
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using StellaOps.Auth.Abstractions;
+using StellaOps.Authority.Storage.Mongo.Documents;
+using StellaOps.Authority.Storage.Mongo.Sessions;
+using StellaOps.Authority.Storage.Mongo.Stores;
+using StellaOps.Authority.Tests.Infrastructure;
+using StellaOps.Configuration;
+using Xunit;
+
+namespace StellaOps.Authority.Tests.AdvisoryAi;
+
+public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture
+{
+ private readonly AuthorityWebApplicationFactory factory;
+ private TestLoginAttemptStore? lastLoginAttemptStore;
+
+ public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
+ {
+ this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
+ }
+
+ [Fact]
+ public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
+ {
+ using var client = CreateClient(options =>
+ {
+ options.AdvisoryAi.RemoteInference.Enabled = false;
+ options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
+ options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
+ });
+
+ var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", CreatePayload("cloud-openai"));
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Equal("remote_inference_disabled", body!["error"]);
+ }
+
+ [Fact]
+ public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
+ {
+ using var client = CreateClient(options =>
+ {
+ SeedRemoteInferenceEnabled(options);
+ SeedTenantConsent(options);
+ options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
+ options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
+ options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
+ options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
+ });
+
+ client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
+
+ var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", CreatePayload("cloud-openai"));
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Equal("remote_inference_consent_required", body!["error"]);
+ }
+
+ [Fact]
+ public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
+ {
+ using var client = CreateClient(options =>
+ {
+ SeedRemoteInferenceEnabled(options);
+ SeedTenantConsent(options);
+ });
+
+ client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
+
+ var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", CreatePayload("other-profile"));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Equal("profile_not_allowed", body!["error"]);
+ }
+
+ [Fact]
+ public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
+ {
+ using var client = CreateClient(options =>
+ {
+ SeedRemoteInferenceEnabled(options);
+ SeedTenantConsent(options);
+ });
+
+ client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
+
+ var payload = CreatePayload("cloud-openai", "Generate remediation plan.");
+ var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
+
+ if (response.StatusCode != HttpStatusCode.OK)
+ {
+ var errorBody = await response.Content.ReadAsStringAsync();
+ throw new Xunit.Sdk.XunitException($"Unexpected status {response.StatusCode}: {errorBody}");
+ }
+
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.Equal("logged", body!["status"]);
+
+ var expectedHash = ComputeSha256(payload.Prompt);
+ Assert.Equal(expectedHash, body["prompt_hash"]);
+
+ var doc = Assert.Single(lastLoginAttemptStore!.Records.Where(record => record.EventType == "authority.advisory_ai.remote_inference"));
+ Assert.Equal("authority.advisory_ai.remote_inference", doc.EventType);
+ var properties = doc.Properties.ToDictionary(p => p.Name, p => p.Value);
+ Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
+ Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
+ Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
+ Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
+ }
+
+ private HttpClient CreateClient(Action? configureOptions = null)
+ {
+ const string schemeName = "StellaOpsBearer";
+ var builder = factory.WithWebHostBuilder(hostBuilder =>
+ {
+ hostBuilder.ConfigureTestServices(services =>
+ {
+ var store = new TestLoginAttemptStore();
+ lastLoginAttemptStore = store;
+ services.AddSingleton(store);
+
+ services.AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = schemeName;
+ options.DefaultChallengeScheme = schemeName;
+ })
+ .AddScheme