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(schemeName, _ => { }); + + services.PostConfigure(opts => + { + opts.Issuer ??= new Uri("https://authority.test"); + SeedRemoteInferenceEnabled(opts); + SeedTenantConsent(opts); + configureOptions?.Invoke(opts); + }); + }); + }); + + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName); + return client; + } + + private static RemoteInferenceLogRequest CreatePayload(string profile, string? prompt = null, string taskType = "analysis") => new() + { + Profile = profile, + Prompt = prompt ?? "Test prompt", + TaskType = taskType, + PromptHash = null, + PromptAlgorithm = null, + OriginalFileName = "input.jsonl" + }; + + 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) + { + options.Tenants.Clear(); + options.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" + } + } + }); + } + + private static string ComputeSha256(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return string.Empty; + } + + var bytes = Encoding.UTF8.GetBytes(input); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private sealed class RemoteInferenceLogRequest + { + [JsonPropertyName("profile")] + public string Profile { get; set; } = string.Empty; + + [JsonPropertyName("prompt")] + public string? Prompt { get; set; } + + [JsonPropertyName("prompt_hash")] + public string? PromptHash { get; set; } + + [JsonPropertyName("prompt_algorithm")] + public string? PromptAlgorithm { get; set; } + + [JsonPropertyName("original_file_name")] + public string? OriginalFileName { get; set; } + + [JsonPropertyName("taskType")] + public string? TaskType { get; set; } + } + + private sealed class TestLoginAttemptStore : IAuthorityLoginAttemptStore + { + public List Records { get; } = new(); + + public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + Records.Add(document); + return ValueTask.CompletedTask; + } + + public ValueTask> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult>(Array.Empty()); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AirgapAuditEndpointsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AirgapAuditEndpointsTests.cs index 19645ac2f..1f79306b2 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AirgapAuditEndpointsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Airgap/AirgapAuditEndpointsTests.cs @@ -11,11 +11,11 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Time.Testing; -using MongoDB.Driver; using StellaOps.Auth.Abstractions; using StellaOps.Authority.Airgap; -using StellaOps.Authority.Storage.Mongo; using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.Tests.Infrastructure; using Xunit; @@ -36,8 +36,6 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture.Empty); var request = new AirgapAuditRecordRequestDto { @@ -63,7 +61,7 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture.Filter.Eq(d => d.BundleId, "mirror-bundle-001")).SingleAsync(); + var stored = Assert.Single(_airgapStore!.Records, d => d.BundleId == "mirror-bundle-001"); Assert.Equal("completed", stored.Status); Assert.Equal(TenantId, stored.Tenant); Assert.Equal("test-client", stored.ClientId); @@ -116,9 +114,6 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture.Empty); - await PostAsync(client, "bundle-A", "completed", timeProvider); timeProvider.Advance(TimeSpan.FromMinutes(1)); await PostAsync(client, "bundle-B", "completed", timeProvider); @@ -167,11 +162,16 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture { hostBuilder.ConfigureTestServices(services => { + var store = new TestAirgapAuditStore(); + _airgapStore = store; + services.Replace(ServiceDescriptor.Singleton(store)); + services.Replace(ServiceDescriptor.Singleton()); services.Replace(ServiceDescriptor.Singleton(timeProvider)); services.AddAuthentication(options => { @@ -202,11 +202,7 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture GetAuditCollection() - { - var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests"); - return database.GetCollection(AuthorityMongoDefaults.Collections.AirgapAudit); - } + private TestAirgapAuditStore? _airgapStore; private sealed record AirgapAuditRecordRequestDto { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditSinkTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditSinkTests.cs index cc9f110f3..60753614e 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditSinkTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Audit/AuthorityAuditSinkTests.cs @@ -1,9 +1,10 @@ +using System.Linq; using Microsoft.Extensions.Logging; -using MongoDB.Driver; using StellaOps.Authority.Audit; using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Cryptography.Audit; +using StellaOps.Authority.Storage.Mongo.Sessions; namespace StellaOps.Authority.Tests.Audit; @@ -76,10 +77,9 @@ public class AuthorityAuditSinkTests Assert.Equal(record.OccurredAt, document.OccurredAt); Assert.Equal(new[] { "openid", "profile" }, document.Scopes); - var property = Assert.Single(document.Properties); - Assert.Equal("plugin.failed_attempts", property.Name); - Assert.Equal("0", property.Value); - Assert.Equal("none", property.Classification); + var pluginProperty = Assert.Single(document.Properties.Where(property => property.Name == "plugin.failed_attempts")); + Assert.Equal("0", pluginProperty.Value); + Assert.Equal("none", pluginProperty.Classification); var logEntry = Assert.Single(logger.Entries); Assert.Equal(LogLevel.Information, logEntry.Level); @@ -145,7 +145,8 @@ public class AuthorityAuditSinkTests return null; } - var valueProperty = entry.GetType().GetProperty("value"); + var type = entry.GetType(); + var valueProperty = type.GetProperty("Value") ?? type.GetProperty("value"); return valueProperty?.GetValue(entry) as string; } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/BootstrapInviteCleanupServiceTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/BootstrapInviteCleanupServiceTests.cs index a4aa4e81a..dee797049 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/BootstrapInviteCleanupServiceTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/BootstrapInviteCleanupServiceTests.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Time.Testing; using StellaOps.Authority.Bootstrap; using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Stores; -using MongoDB.Driver; +using StellaOps.Authority.Storage.Mongo.Sessions; using StellaOps.Cryptography.Audit; using Xunit; @@ -66,8 +66,11 @@ public sealed class BootstrapInviteCleanupServiceTests public bool ExpireCalled { get; private set; } + public ValueTask FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(invites.FirstOrDefault(i => string.Equals(i.Token, token, StringComparison.Ordinal))); + public ValueTask CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => throw new NotImplementedException(); + => ValueTask.FromResult(document); public ValueTask TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null)); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs index 64b23512b..c311aeec1 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Bootstrap/ServiceAccountAdminEndpointsTests.cs @@ -13,7 +13,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Options; -using MongoDB.Driver; using StellaOps.Auth.Abstractions; using Microsoft.AspNetCore.Routing; using StellaOps.Configuration; @@ -304,7 +303,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture(); - var session = await sessionAccessor.GetSessionAsync(); + var session = await sessionAccessor.GetSessionAsync(CancellationToken.None); var token = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session); Assert.NotNull(token); Assert.Equal("revoked", token!.Status); @@ -512,6 +511,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture= initialCreatedAt); + Assert.Equal(ServiceAccountId, document!.AccountId); + if (isInMemoryStore) + { + Assert.False(string.IsNullOrWhiteSpace(document.Id)); + } + else + { + Assert.Equal(initialId, document.Id); + Assert.Equal(initialCreatedAt, document.CreatedAt); + Assert.True(document.UpdatedAt >= initialCreatedAt); + } } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs index 264795de9..74d74bd7b 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/AuthorityWebApplicationFactory.cs @@ -1,25 +1,27 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Mongo2Go; -using Xunit; - -namespace StellaOps.Authority.Tests.Infrastructure; - -public sealed class AuthorityWebApplicationFactory : WebApplicationFactory, IAsyncLifetime -{ - private readonly MongoDbRunner mongoRunner; - private readonly string tempContentRoot; - - private const string IssuerKey = "STELLAOPS_AUTHORITY_AUTHORITY__ISSUER"; - private const string SchemaVersionKey = "STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION"; - private const string StorageConnectionKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING"; - private const string StorageDatabaseKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__DATABASENAME"; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StellaOps.Authority.Storage.Mongo.Extensions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Postgres; + +namespace StellaOps.Authority.Tests.Infrastructure; + +public sealed class AuthorityWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly string tempContentRoot; + + private const string IssuerKey = "STELLAOPS_AUTHORITY_AUTHORITY__ISSUER"; + private const string SchemaVersionKey = "STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION"; private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED"; private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED"; private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED"; @@ -37,23 +39,22 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory mongoRunner.ConnectionString; - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Development"); - builder.UseContentRoot(tempContentRoot); - builder.ConfigureAppConfiguration((_, configuration) => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["Authority:Issuer"] = "https://authority.test", - ["Authority:SchemaVersion"] = "1", - ["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString, - ["Authority:Storage:DatabaseName"] = "authority-tests", + Environment.SetEnvironmentVariable(StorageConnectionKey, "Host=localhost;Username=test;Password=test;Database=authority-tests"); + Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests"); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseContentRoot(tempContentRoot); + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Authority:Issuer"] = "https://authority.test", + ["Authority:SchemaVersion"] = "1", + ["Authority:Storage:ConnectionString"] = "Host=localhost;Username=test;Password=test;Database=authority-tests", + ["Authority:Storage:DatabaseName"] = "authority-tests", ["Authority:Signing:Enabled"] = "false", ["Authority:Notifications:AckTokens:Enabled"] = "false", ["Authority:Notifications:Webhooks:Enabled"] = "false", ["Authority:Delegation:ServiceAccounts:0:Enabled"] = "true" }); }); - - } - - protected override IHost CreateHost(IHostBuilder builder) - { - builder.ConfigureHostConfiguration(configuration => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["Authority:Issuer"] = "https://authority.test", - ["Authority:SchemaVersion"] = "1", - ["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString, - ["Authority:Storage:DatabaseName"] = "authority-tests", + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddAuthorityMongoStorage(options => + { + options.ConnectionString = "mongodb://localhost/authority-tests"; + options.DatabaseName = "authority-tests"; + }); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureHostConfiguration(configuration => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Authority:Issuer"] = "https://authority.test", + ["Authority:SchemaVersion"] = "1", + ["Authority:Storage:ConnectionString"] = "Host=localhost;Username=test;Password=test;Database=authority-tests", + ["Authority:Storage:DatabaseName"] = "authority-tests", ["Authority:Signing:Enabled"] = "false", ["Authority:Notifications:AckTokens:Enabled"] = "false", ["Authority:Notifications:Webhooks:Enabled"] = "false", ["Authority:Delegation:ServiceAccounts:0:Enabled"] = "true" }); }); - - return base.CreateHost(builder); - } - - public Task InitializeAsync() => Task.CompletedTask; - - private static string LocateRepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = directory.FullName; - if (File.Exists(Path.Combine(candidate, "README.md")) && Directory.Exists(Path.Combine(candidate, "src"))) - { - return candidate; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException("Failed to locate repository root for Authority tests."); - } - + + return base.CreateHost(builder); + } + + public Task InitializeAsync() => Task.CompletedTask; + + private static string LocateRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = directory.FullName; + if (File.Exists(Path.Combine(candidate, "README.md")) && Directory.Exists(Path.Combine(candidate, "src"))) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Failed to locate repository root for Authority tests."); + } + public override async ValueTask DisposeAsync() { - mongoRunner.Dispose(); - Environment.SetEnvironmentVariable(IssuerKey, null); Environment.SetEnvironmentVariable(SchemaVersionKey, null); - Environment.SetEnvironmentVariable(StorageConnectionKey, null); - Environment.SetEnvironmentVariable(StorageDatabaseKey, null); Environment.SetEnvironmentVariable(SigningEnabledKey, null); Environment.SetEnvironmentVariable(AckTokensEnabledKey, null); Environment.SetEnvironmentVariable(WebhooksEnabledKey, null); @@ -160,6 +175,8 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory DisposeAsync().AsTask(); -} +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAirgapAuditStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/TestAirgapAuditStore.cs new file mode 100644 index 000000000..a41a9c058 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Infrastructure/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.Tests.Infrastructure; + +internal sealed class TestAirgapAuditStore : IAuthorityAirgapAuditStore +{ + private readonly ConcurrentBag records = 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 hasMore = page.Count > take; + var items = hasMore ? page.Take(take).ToList() : page; + var next = hasMore ? items[^1].Id : null; + + return ValueTask.FromResult(new AuthorityAirgapAuditQueryResult(items, next)); + } + + public IReadOnlyCollection Records => records.ToArray(); +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenIssuerTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenIssuerTests.cs index 9d9e8c594..3e6b10347 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenIssuerTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenIssuerTests.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -152,6 +153,7 @@ public sealed class AuthorityAckTokenIssuerTests var services = new ServiceCollection(); services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.AddSingleton(new TestHostEnvironment(basePath)); + services.AddSingleton(new ConfigurationBuilder().Build()); services.AddSingleton(options); services.AddSingleton>(Options.Create(options)); services.AddSingleton(TimeProvider.System); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenKeyManagerTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenKeyManagerTests.cs index c51789e0b..05b64e525 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenKeyManagerTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Notifications/AuthorityAckTokenKeyManagerTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; @@ -108,6 +109,7 @@ public sealed class AuthorityAckTokenKeyManagerTests var services = new ServiceCollection(); services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.AddSingleton(new TestHostEnvironment(basePath)); + services.AddSingleton(new ConfigurationBuilder().Build()); services.AddSingleton(options); services.AddSingleton>(Options.Create(options)); services.AddSingleton(TimeProvider.System); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs index 3578fe4d7..a1b25a6fa 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/ClientCredentialsAndTokenHandlersTests.cs @@ -36,8 +36,6 @@ using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Authority.RateLimiting; using StellaOps.Cryptography.Audit; using Xunit; -using MongoDB.Bson; -using MongoDB.Driver; using static StellaOps.Authority.Tests.OpenIddict.TestHelpers; namespace StellaOps.Authority.Tests.OpenIddict; @@ -3868,16 +3866,9 @@ public class TokenValidationHandlersTests TokenId = "token-replay", Status = "valid", ClientId = "agent", - Devices = new List + Devices = new List { - new BsonDocument - { - { "remoteAddress", "10.0.0.1" }, - { "userAgent", "agent/1.0" }, - { "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) }, - { "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) }, - { "useCount", 2 } - } + "10.0.0.1|agent/1.0" } }; @@ -4166,6 +4157,22 @@ internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore return ValueTask.FromResult>(results); } + public ValueTask> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null) + { + var query = accounts.Values.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(tenant)) + { + var normalizedTenant = tenant.Trim(); + query = query.Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase)); + } + + var ordered = query + .OrderBy(static account => account.AccountId, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return ValueTask.FromResult>(ordered); + } + public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) { ArgumentNullException.ThrowIfNull(document); @@ -4205,16 +4212,16 @@ internal sealed class TestTokenStore : IAuthorityTokenStore public ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult(null); + public ValueTask> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult>(Inserted is not null && string.Equals(Inserted.SubjectId, subjectId, StringComparison.OrdinalIgnoreCase) ? new[] { Inserted } : Array.Empty()); + public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.CompletedTask; - public ValueTask DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null) - => ValueTask.FromResult(0L); - public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent)); - public ValueTask> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null) + public ValueTask> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult>(Array.Empty()); public ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) { @@ -4276,6 +4283,17 @@ internal sealed class TestTokenStore : IAuthorityTokenStore return ValueTask.FromResult>(Array.Empty()); } + public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => InsertAsync(document, cancellationToken, session); + + public ValueTask RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(false); + + public ValueTask RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(0); + + public ValueTask RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => ValueTask.FromResult(0); } internal sealed class TestClaimsEnricher : IClaimsEnricher @@ -4459,10 +4477,10 @@ internal sealed class StubCertificateValidator : IAuthorityClientCertificateVali internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor { - public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) - => ValueTask.FromResult(null!); + public IClientSessionHandle? CurrentSession => null; - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask GetSessionAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult(null); } public class ObservabilityIncidentTokenHandlerTests @@ -4710,7 +4728,8 @@ public class ObservabilityIncidentTokenHandlerTests [Fact] public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope() { - var clientStore = new TestClientStore(CreateClient()); + var clientDocument = CreateClient(); + var clientStore = new TestClientStore(clientDocument); var handler = new ValidateRefreshTokenGrantHandler( clientStore, new NoopCertificateValidator(), @@ -4722,7 +4741,8 @@ public class ObservabilityIncidentTokenHandlerTests Options = new OpenIddictServerOptions(), Request = new OpenIddictRequest { - GrantType = OpenIddictConstants.GrantTypes.RefreshToken + GrantType = OpenIddictConstants.GrantTypes.RefreshToken, + ClientId = clientDocument.ClientId } }; @@ -4763,7 +4783,8 @@ public class ObservabilityIncidentTokenHandlerTests Options = new OpenIddictServerOptions(), Request = new OpenIddictRequest { - GrantType = OpenIddictConstants.GrantTypes.RefreshToken + GrantType = OpenIddictConstants.GrantTypes.RefreshToken, + ClientId = clientDocument.ClientId } }; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs index 2e9cbd572..5ac0d2d6d 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/PasswordGrantHandlersTests.cs @@ -1,52 +1,54 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using MongoDB.Driver; -using OpenIddict.Abstractions; -using OpenIddict.Server; -using OpenIddict.Server.AspNetCore; -using OpenIddict.Extensions; -using Microsoft.IdentityModel.Tokens; -using StellaOps.Authority.OpenIddict; -using StellaOps.Authority.OpenIddict.Handlers; -using StellaOps.Authority.Plugins.Abstractions; -using StellaOps.Authority.RateLimiting; -using StellaOps.Authority.Storage.Mongo.Documents; -using StellaOps.Authority.Storage.Mongo.Stores; -using StellaOps.Cryptography.Audit; -using StellaOps.Configuration; -using StellaOps.Auth.Abstractions; -using StellaOps.Auth.Security.Dpop; -using StellaOps.Authority.Security; -using Xunit; - -namespace StellaOps.Authority.Tests.OpenIddict; - -public class PasswordGrantHandlersTests +using System.Diagnostics; +using System.Globalization; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; +using OpenIddict.Extensions; +using Microsoft.IdentityModel.Tokens; +using StellaOps.Authority.OpenIddict; +using StellaOps.Authority.OpenIddict.Handlers; +using StellaOps.Authority.Plugins.Abstractions; +using StellaOps.Authority.RateLimiting; +using StellaOps.Authority.Airgap; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Cryptography.Audit; +using StellaOps.Configuration; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.Security.Dpop; +using StellaOps.Authority.Security; +using Xunit; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public class PasswordGrantHandlersTests { - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); - - [Fact] - public async Task HandlePasswordGrant_EmitsSuccessAuditEvent() - { + private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); + private readonly TestCredentialAuditContextAccessor auditContextAccessor = new(); + + [Fact] + public async Task HandlePasswordGrant_EmitsSuccessAuditEvent() + { var sink = new TestAuthEventSink(); var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!"); @@ -56,195 +58,196 @@ public class PasswordGrantHandlersTests var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); Assert.Equal("tenant-alpha", successEvent.Tenant.Value); - var metadata = metadataAccessor.GetMetadata(); - Assert.Equal("tenant-alpha", metadata?.Tenant); - } - - [Fact] - public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientDocument = CreateClientDocument(); - clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true"; - var clientStore = new StubClientStore(clientDocument); - var sealedValidator = new TestSealedModeEvidenceValidator - { - Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null) - }; - var handler = new ValidatePasswordGrantHandler( - registry, - TestActivitySource, - sink, - metadataAccessor, - clientStore, - TimeProvider.System, - NullLogger.Instance, - sealedValidator); - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(CreatePasswordTransaction("alice", "Password1!")); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]); - } - - [Fact] - public async Task ValidateDpopProofHandler_RejectsPasswordGrant_WhenProofMissing() - { - var options = CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Dpop.Enabled = true; - opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; - }); - - var clientDocument = CreateClientDocument(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - - var clientStore = new StubClientStore(clientDocument); - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var validator = new DpopProofValidator( - Options.Create(new DpopValidationOptions()), - new InMemoryDpopReplayCache(TimeProvider.System), - TimeProvider.System, - NullLogger.Instance); - var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); - - var handler = new ValidateDpopProofHandler( - options, - clientStore, - validator, - nonceStore, - metadataAccessor, - sink, - TimeProvider.System, - TestActivitySource, - TestInstruments.Meter, - NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!"); - transaction.Options = new OpenIddictServerOptions(); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("authority.test"); - httpContext.Request.Path = "/token"; - transaction.Properties[typeof(HttpContext).FullName!] = httpContext; - - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); - Assert.Equal("DPoP proof is required.", context.ErrorDescription); - } - - [Fact] - public async Task HandlePasswordGrant_AppliesDpopConfirmationClaims() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientDocument = CreateClientDocument(); - clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; - - var clientStore = new StubClientStore(clientDocument); - - var options = CreateAuthorityOptions(opts => - { - opts.Security.SenderConstraints.Dpop.Enabled = true; - opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; - }); - - var dpopValidator = new DpopProofValidator( - Options.Create(new DpopValidationOptions()), - new InMemoryDpopReplayCache(TimeProvider.System), - TimeProvider.System, - NullLogger.Instance); - var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); - - var dpopHandler = new ValidateDpopProofHandler( - options, - clientStore, - dpopValidator, - nonceStore, - metadataAccessor, - sink, - TimeProvider.System, - TestActivitySource, - TestInstruments.Meter, - NullLogger.Instance); - - var validate = new ValidatePasswordGrantHandler( - registry, - TestActivitySource, - sink, - metadataAccessor, - clientStore, - TimeProvider.System, - NullLogger.Instance); - - var handle = new HandlePasswordGrantHandler( - registry, - clientStore, - TestActivitySource, - sink, - metadataAccessor, - TimeProvider.System, - NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!"); - transaction.Options = new OpenIddictServerOptions(); - - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "POST"; - httpContext.Request.Scheme = "https"; - httpContext.Request.Host = new HostString("authority.test"); - httpContext.Request.Path = "/token"; - - using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var securityKey = new ECDsaSecurityKey(ecdsa) - { - KeyId = Guid.NewGuid().ToString("N") - }; - - var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); - var expectedThumbprint = TestHelpers.ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); - - var now = TimeProvider.System.GetUtcNow(); - var proof = TestHelpers.CreateDpopProof( - securityKey, - httpContext.Request.Method, - httpContext.Request.GetDisplayUrl(), - now.ToUnixTimeSeconds()); - httpContext.Request.Headers["DPoP"] = proof; - transaction.Properties[typeof(HttpContext).FullName!] = httpContext; - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await dpopHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - await validate.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handle.HandleAsync(handleContext); - - var principal = handleContext.Principal; - Assert.NotNull(principal); - var confirmation = principal!.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); - Assert.False(string.IsNullOrWhiteSpace(confirmation)); - using var confirmationJson = JsonDocument.Parse(confirmation!); - Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); - Assert.Equal(AuthoritySenderConstraintKinds.Dpop, principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType)); - } - + var metadata = metadataAccessor.GetMetadata(); + Assert.Equal("tenant-alpha", metadata?.Tenant); + } + + [Fact] + public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientDocument = CreateClientDocument(); + clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true"; + var clientStore = new StubClientStore(clientDocument); + var sealedValidator = new TestSealedModeEvidenceValidator + { + Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null) + }; + var handler = new ValidatePasswordGrantHandler( + registry, + TestActivitySource, + sink, + metadataAccessor, + clientStore, + TimeProvider.System, + NullLogger.Instance, + sealedValidator, + auditContextAccessor); + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(CreatePasswordTransaction("alice", "Password1!")); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]); + } + + [Fact] + public async Task ValidateDpopProofHandler_RejectsPasswordGrant_WhenProofMissing() + { + var options = CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Dpop.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; + }); + + var clientDocument = CreateClientDocument(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + + var clientStore = new StubClientStore(clientDocument); + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var validator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var handler = new ValidateDpopProofHandler( + options, + clientStore, + validator, + nonceStore, + metadataAccessor, + sink, + TimeProvider.System, + TestActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var transaction = CreatePasswordTransaction("alice", "Password1!"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await handler.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); + Assert.Equal("DPoP proof is required.", context.ErrorDescription); + } + + [Fact] + public async Task HandlePasswordGrant_AppliesDpopConfirmationClaims() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientDocument = CreateClientDocument(); + clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop; + + var clientStore = new StubClientStore(clientDocument); + + var options = CreateAuthorityOptions(opts => + { + opts.Security.SenderConstraints.Dpop.Enabled = true; + opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false; + }); + + var dpopValidator = new DpopProofValidator( + Options.Create(new DpopValidationOptions()), + new InMemoryDpopReplayCache(TimeProvider.System), + TimeProvider.System, + NullLogger.Instance); + var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger.Instance); + + var dpopHandler = new ValidateDpopProofHandler( + options, + clientStore, + dpopValidator, + nonceStore, + metadataAccessor, + sink, + TimeProvider.System, + TestActivitySource, + TestInstruments.Meter, + NullLogger.Instance); + + var validate = new ValidatePasswordGrantHandler( + registry, + TestActivitySource, + sink, + metadataAccessor, + clientStore, + TimeProvider.System, + NullLogger.Instance, auditContextAccessor: auditContextAccessor); + + var handle = new HandlePasswordGrantHandler( + registry, + clientStore, + TestActivitySource, + sink, + metadataAccessor, + TimeProvider.System, + NullLogger.Instance, auditContextAccessor); + + var transaction = CreatePasswordTransaction("alice", "Password1!"); + transaction.Options = new OpenIddictServerOptions(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "POST"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("authority.test"); + httpContext.Request.Path = "/token"; + + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var securityKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Guid.NewGuid().ToString("N") + }; + + var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); + var expectedThumbprint = TestHelpers.ConvertThumbprintToString(jwk.ComputeJwkThumbprint()); + + var now = TimeProvider.System.GetUtcNow(); + var proof = TestHelpers.CreateDpopProof( + securityKey, + httpContext.Request.Method, + httpContext.Request.GetDisplayUrl(), + now.ToUnixTimeSeconds()); + httpContext.Request.Headers["DPoP"] = proof; + transaction.Properties[typeof(HttpContext).FullName!] = httpContext; + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await dpopHandler.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + await validate.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handle.HandleAsync(handleContext); + + var principal = handleContext.Principal; + Assert.NotNull(principal); + var confirmation = principal!.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + Assert.False(string.IsNullOrWhiteSpace(confirmation)); + using var confirmationJson = JsonDocument.Parse(confirmation!); + Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString()); + Assert.Equal(AuthoritySenderConstraintKinds.Dpop, principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType)); + } + [Fact] public async Task HandlePasswordGrant_EmitsFailureAuditEvent() { @@ -252,8 +255,8 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new FailureCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "BadPassword!"); @@ -270,7 +273,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("advisory:read aoc:verify")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory:read"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -291,7 +294,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("obs:incident")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -311,8 +314,8 @@ public class PasswordGrantHandlersTests var registry = CreateRegistry(new SuccessCredentialStore()); var clientDocument = CreateClientDocument("obs:incident"); var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "obs:incident"); SetParameter(transaction, "incident_reason", "Sev1 drill activation"); @@ -338,7 +341,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("advisory-ai:view aoc:verify")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory-ai:view"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -361,7 +364,7 @@ public class PasswordGrantHandlersTests var clientDocument = CreateClientDocument("advisory-ai:view"); clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant); var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory-ai:view"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -382,7 +385,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("signals:write signals:read signals:admin aoc:verify")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "signals:write"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -403,7 +406,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); SetParameter(transaction, "policy_ticket", "CR-1001"); @@ -429,7 +432,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); SetParameter(transaction, "policy_reason", "Publish approved policy"); @@ -451,7 +454,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); SetParameter(transaction, "policy_reason", "Publish approved policy"); @@ -473,7 +476,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("policy:publish")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish"); SetParameter(transaction, "policy_reason", "Publish approved policy"); @@ -492,16 +495,16 @@ public class PasswordGrantHandlersTests [Theory] [InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)] [InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)] - public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation) - { - var sink = new TestAuthEventSink(); + public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation) + { + var sink = new TestAuthEventSink(); var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientDocument = CreateClientDocument(scope); var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", scope); SetParameter(transaction, "policy_reason", "Promote approved policy"); @@ -521,65 +524,65 @@ public class PasswordGrantHandlersTests Assert.Equal(new string('c', 64), principal.GetClaim(StellaOpsClaimTypes.PolicyDigest)); Assert.Equal("Promote approved policy", principal.GetClaim(StellaOpsClaimTypes.PolicyReason)); Assert.Equal("CR-1004", principal.GetClaim(StellaOpsClaimTypes.PolicyTicket)); - Assert.Contains(sink.Events, record => - record.EventType == "authority.password.grant" && - record.Outcome == AuthEventOutcome.Success && - record.Properties.Any(property => property.Name == "policy.action")); - } - - [Fact] - public async Task ValidatePasswordGrant_Rejects_WhenPackApprovalMetadataMissing() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve"); - var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - - await validate.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); - Assert.Equal("Pack approval tokens require pack_run_id.", context.ErrorDescription); - Assert.Equal(StellaOpsScopes.PacksApprove, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); - } - - [Fact] - public async Task HandlePasswordGrant_AddsPackApprovalClaims() - { - var sink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var registry = CreateRegistry(new SuccessCredentialStore()); - var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve")); - - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); - - var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve"); - SetParameter(transaction, AuthorityOpenIddictConstants.PackRunIdParameterName, "run-123"); - SetParameter(transaction, AuthorityOpenIddictConstants.PackGateIdParameterName, "security-review"); - SetParameter(transaction, AuthorityOpenIddictConstants.PackPlanHashParameterName, new string(a, 64)); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validate.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handle.HandleAsync(handleContext); - - Assert.False(handleContext.IsRejected); - var principal = Assert.IsType(handleContext.Principal); - Assert.Equal("run-123", principal.GetClaim(StellaOpsClaimTypes.PackRunId)); - Assert.Equal("security-review", principal.GetClaim(StellaOpsClaimTypes.PackGateId)); - Assert.Equal(new string(a, 64), principal.GetClaim(StellaOpsClaimTypes.PackPlanHash)); - Assert.Contains(sink.Events, record => - record.EventType == "authority.password.grant" && - record.Outcome == AuthEventOutcome.Success && - record.Properties.Any(property => property.Name == "pack.run_id")); - } + Assert.Contains(sink.Events, record => + record.EventType == "authority.password.grant" && + record.Outcome == AuthEventOutcome.Success && + record.Properties.Any(property => property.Name == "policy.action")); + } + + [Fact] + public async Task ValidatePasswordGrant_Rejects_WhenPackApprovalMetadataMissing() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve")); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve"); + var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + + await validate.HandleAsync(context); + + Assert.True(context.IsRejected); + Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error); + Assert.Equal("Pack approval tokens require 'pack_run_id'.", context.ErrorDescription); + Assert.Equal(StellaOpsScopes.PacksApprove, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]); + } + + [Fact] + public async Task HandlePasswordGrant_AddsPackApprovalClaims() + { + var sink = new TestAuthEventSink(); + var metadataAccessor = new TestRateLimiterMetadataAccessor(); + var registry = CreateRegistry(new SuccessCredentialStore()); + var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve")); + + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); + + var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve"); + SetParameter(transaction, AuthorityOpenIddictConstants.PackRunIdParameterName, "run-123"); + SetParameter(transaction, AuthorityOpenIddictConstants.PackGateIdParameterName, "security-review"); + SetParameter(transaction, AuthorityOpenIddictConstants.PackPlanHashParameterName, new string('a', 64)); + + var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); + await validate.HandleAsync(validateContext); + Assert.False(validateContext.IsRejected); + + var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); + await handle.HandleAsync(handleContext); + + Assert.False(handleContext.IsRejected); + var principal = Assert.IsType(handleContext.Principal); + Assert.Equal("run-123", principal.GetClaim(StellaOpsClaimTypes.PackRunId)); + Assert.Equal("security-review", principal.GetClaim(StellaOpsClaimTypes.PackGateId)); + Assert.Equal(new string('a', 64), principal.GetClaim(StellaOpsClaimTypes.PackPlanHash)); + Assert.Contains(sink.Events, record => + record.EventType == "authority.password.grant" && + record.Outcome == AuthEventOutcome.Success && + record.Properties.Any(property => property.Name == "pack.run_id")); + } [Fact] public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant() @@ -590,7 +593,7 @@ public class PasswordGrantHandlersTests var clientDocument = CreateClientDocument("policy:author"); clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant); var clientStore = new StubClientStore(clientDocument); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -611,7 +614,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument("policy:author")); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -629,8 +632,8 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new LockoutCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Locked!"); @@ -647,7 +650,7 @@ public class PasswordGrantHandlersTests var metadataAccessor = new TestRateLimiterMetadataAccessor(); var registry = CreateRegistry(new SuccessCredentialStore()); var clientStore = new StubClientStore(CreateClientDocument()); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!"); SetParameter(transaction, "unexpected_param", "value"); @@ -677,7 +680,7 @@ public class PasswordGrantHandlersTests RequireMfa = true }); }); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve"); var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -706,8 +709,8 @@ public class PasswordGrantHandlersTests RequireMfa = true }); }); - var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance); - var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance); + var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger.Instance, auditContextAccessor: auditContextAccessor); + var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger.Instance, auditContextAccessor); var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve"); var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); @@ -819,7 +822,7 @@ public class PasswordGrantHandlersTests Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); Credentials = store; ClaimsEnricher = new NoopClaimsEnricher(); - Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false, SupportsBootstrap: false); + Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false, SupportsBootstrap: false); } public string Name { get; } @@ -895,15 +898,40 @@ public class PasswordGrantHandlersTests => ValueTask.FromResult(null); } - private sealed class TestSealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator - { - public AuthoritySealedModeValidationResult Result { get; set; } = AuthoritySealedModeValidationResult.Success(null, null); - - public ValueTask ValidateAsync(CancellationToken cancellationToken) - => ValueTask.FromResult(Result); - } - - private sealed class StubClientStore : IAuthorityClientStore + private sealed class TestCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor + { + private AuthorityCredentialAuditContext? current; + + public AuthorityCredentialAuditContext? Current => current; + + public IDisposable BeginScope(AuthorityCredentialAuditContext context) + { + current = context; + return new Scope(() => current = null); + } + + private sealed class Scope : IDisposable + { + private readonly Action onDispose; + + public Scope(Action onDispose) + { + this.onDispose = onDispose; + } + + public void Dispose() => onDispose(); + } + } + + private sealed class TestSealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator + { + public AuthoritySealedModeValidationResult Result { get; set; } = AuthoritySealedModeValidationResult.Success(null, null); + + public ValueTask ValidateAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(Result); + } + + private sealed class StubClientStore : IAuthorityClientStore { private AuthorityClientDocument? document; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs index 3ca4c87d4..99fcf4596 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs @@ -1,410 +1,77 @@ -using System.Collections.Generic; -using System.Diagnostics; -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; -using MongoDB.Driver; -using MongoDB.Bson; -using OpenIddict.Abstractions; -using OpenIddict.Server; -using StellaOps.Authority; -using StellaOps.Authority.OpenIddict; -using StellaOps.Authority.OpenIddict.Handlers; -using StellaOps.Authority.Plugins.Abstractions; -using StellaOps.Authority.Storage.Mongo; -using StellaOps.Authority.Storage.Mongo.Documents; -using StellaOps.Authority.Storage.Mongo.Extensions; -using StellaOps.Authority.Storage.Mongo.Initialization; -using StellaOps.Authority.Storage.Mongo.Sessions; -using StellaOps.Auth.Abstractions; -using StellaOps.Authority.Storage.Mongo.Stores; -using StellaOps.Concelier.Testing; -using StellaOps.Authority.RateLimiting; -using StellaOps.Authority.Security; -using StellaOps.Cryptography.Audit; -using Xunit; - -namespace StellaOps.Authority.Tests.OpenIddict; - -[Collection("mongo-fixture")] -public sealed class TokenPersistenceIntegrationTests -{ - private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.Persistence"); - private readonly MongoIntegrationFixture fixture; - - public TokenPersistenceIntegrationTests(MongoIntegrationFixture fixture) - => this.fixture = fixture ?? throw new ArgumentNullException(nameof(fixture)); - - [Fact] - public async Task HandleClientCredentials_PersistsTokenInMongo() - { - await ResetCollectionsAsync(); - - var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero); - var clock = new FakeTimeProvider(issuedAt); - - await using var provider = await BuildMongoProviderAsync(clock); - - var clientStore = provider.GetRequiredService(); - var tokenStore = provider.GetRequiredService(); - var serviceAccountStore = provider.GetRequiredService(); - - var clientDocument = TestHelpers.CreateClient( - secret: "s3cr3t!", - allowedGrantTypes: "client_credentials", - allowedScopes: "jobs:trigger jobs:read", - tenant: "tenant-alpha"); - - await clientStore.UpsertAsync(clientDocument, CancellationToken.None); - - var registry = TestHelpers.CreateRegistry( - withClientProvisioning: true, - clientDescriptor: TestHelpers.CreateDescriptor(clientDocument)); - - var authSink = new TestAuthEventSink(); - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - await using var scope = provider.CreateAsyncScope(); - var sessionAccessor = scope.ServiceProvider.GetRequiredService(); - var options = TestHelpers.CreateAuthorityOptions(); - var validateHandler = new ValidateClientCredentialsHandler( - clientStore, - registry, - TestActivitySource, - authSink, - metadataAccessor, - serviceAccountStore, - tokenStore, - clock, - new NoopCertificateValidator(), - new HttpContextAccessor(), - options, - NullLogger.Instance); - var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, metadataAccessor, clock, TestActivitySource, NullLogger.Instance); - var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger.Instance); - - var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger"); - transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15); - - var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); - await validateHandler.HandleAsync(validateContext); - Assert.False(validateContext.IsRejected); - - var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); - await handleHandler.HandleAsync(handleContext); - - Assert.True(handleContext.IsRequestHandled); - var principal = Assert.IsType(handleContext.Principal); - var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); - Assert.False(string.IsNullOrWhiteSpace(tokenId)); - Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project); - - var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) - { - Principal = principal, - AccessTokenPrincipal = principal - }; - - await persistHandler.HandleAsync(signInContext); - - var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None); - Assert.NotNull(stored); - Assert.Equal(clientDocument.ClientId, stored!.ClientId); - Assert.Equal(OpenIddictConstants.TokenTypeHints.AccessToken, stored.Type); - Assert.Equal("valid", stored.Status); - Assert.Equal(issuedAt, stored.CreatedAt); - Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt); - Assert.Equal(new[] { "jobs:trigger" }, stored.Scope); - Assert.Equal("tenant-alpha", stored.Tenant); - Assert.Equal(StellaOpsTenancyDefaults.AnyProject, stored.Project); - } - - [Fact] - public async Task ValidateAccessTokenHandler_RejectsRevokedRefreshTokenPersistedInMongo() - { - await ResetCollectionsAsync(); - - var now = new DateTimeOffset(2025, 10, 10, 14, 0, 0, TimeSpan.Zero); - var clock = new FakeTimeProvider(now); - - await using var provider = await BuildMongoProviderAsync(clock); - - var clientStore = provider.GetRequiredService(); - var tokenStore = provider.GetRequiredService(); - - var clientDocument = TestHelpers.CreateClient( - secret: null, - clientType: "public", - allowedGrantTypes: "password refresh_token", - allowedScopes: "openid profile jobs:read", - tenant: "tenant-alpha"); - - await clientStore.UpsertAsync(clientDocument, CancellationToken.None); - - var descriptor = TestHelpers.CreateDescriptor(clientDocument); - var userDescriptor = new AuthorityUserDescriptor("subject-1", "alice", displayName: "Alice", requiresPasswordReset: false); - - var plugin = TestHelpers.CreatePlugin( - name: clientDocument.Plugin ?? "standard", - supportsClientProvisioning: true, - descriptor, - userDescriptor); - - var registry = TestHelpers.CreateRegistryFromPlugins(plugin); - - const string revokedTokenId = "refresh-token-1"; - var refreshToken = new AuthorityTokenDocument - { - TokenId = revokedTokenId, - Type = OpenIddictConstants.TokenTypeHints.RefreshToken, - SubjectId = userDescriptor.SubjectId, - ClientId = clientDocument.ClientId, - Scope = new List { "openid", "profile" }, - Status = "valid", - CreatedAt = now.AddMinutes(-5), - ExpiresAt = now.AddHours(4), - ReferenceId = "refresh-reference-1" - }; - - await tokenStore.InsertAsync(refreshToken, CancellationToken.None); - - var revokedAt = now.AddMinutes(1); - await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, "manual", null, null, CancellationToken.None); - - var metadataAccessor = new TestRateLimiterMetadataAccessor(); - var auditSink = new TestAuthEventSink(); - await using var scope = provider.CreateAsyncScope(); - var sessionAccessor = scope.ServiceProvider.GetRequiredService(); - var handler = new ValidateAccessTokenHandler( - tokenStore, - sessionAccessor, - clientStore, - registry, - metadataAccessor, - auditSink, - clock, - TestActivitySource, - TestInstruments.Meter, - NullLogger.Instance); - - var transaction = new OpenIddictServerTransaction - { - EndpointType = OpenIddictServerEndpointType.Token, - Options = new OpenIddictServerOptions(), - Request = new OpenIddictRequest - { - GrantType = OpenIddictConstants.GrantTypes.RefreshToken - } - }; - - var principal = TestHelpers.CreatePrincipal( - clientDocument.ClientId, - revokedTokenId, - plugin.Name, - userDescriptor.SubjectId); - - var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) - { - Principal = principal, - TokenId = revokedTokenId - }; - - await handler.HandleAsync(context); - - Assert.True(context.IsRejected); - Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); - - var stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None); - Assert.NotNull(stored); - Assert.Equal("revoked", stored!.Status); - Assert.Equal(revokedAt, stored.RevokedAt); - Assert.Equal("manual", stored.RevokedReason); - } - - [Fact] - public async Task RecordUsageAsync_FlagsSuspectedReplay_OnNewDeviceFingerprint() - { - await ResetCollectionsAsync(); - - var issuedAt = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero); - var clock = new FakeTimeProvider(issuedAt); - - await using var provider = await BuildMongoProviderAsync(clock); - - var tokenStore = provider.GetRequiredService(); - - var tokenDocument = new AuthorityTokenDocument - { - TokenId = "token-replay", - Type = OpenIddictConstants.TokenTypeHints.AccessToken, - ClientId = "client-1", - Status = "valid", - CreatedAt = issuedAt, - Devices = new List - { - new BsonDocument - { - { "remoteAddress", "10.0.0.1" }, - { "userAgent", "agent/1.0" }, - { "firstSeen", BsonDateTime.Create(issuedAt.AddMinutes(-10).UtcDateTime) }, - { "lastSeen", BsonDateTime.Create(issuedAt.AddMinutes(-5).UtcDateTime) }, - { "useCount", 2 } - } - } - }; - - await tokenStore.InsertAsync(tokenDocument, CancellationToken.None); - - var result = await tokenStore.RecordUsageAsync( - "token-replay", - remoteAddress: "10.0.0.2", - userAgent: "agent/2.0", - observedAt: clock.GetUtcNow(), - CancellationToken.None); - - Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, result.Status); - - var stored = await tokenStore.FindByTokenIdAsync("token-replay", CancellationToken.None); - Assert.NotNull(stored); - Assert.Equal(2, stored!.Devices?.Count); - Assert.Contains(stored.Devices!, doc => - { - var remote = doc.TryGetValue("remoteAddress", out var ra) && ra.IsString ? ra.AsString : null; - var agentValue = doc.TryGetValue("userAgent", out var ua) && ua.IsString ? ua.AsString : null; - return remote == "10.0.0.2" && agentValue == "agent/2.0"; - }); - } - - [Fact] - public async Task MongoSessions_ProvideReadYourWriteAfterPrimaryElection() - { - await ResetCollectionsAsync(); - - var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); - await using var provider = await BuildMongoProviderAsync(clock); - - var tokenStore = provider.GetRequiredService(); - - await using var scope = provider.CreateAsyncScope(); - var sessionAccessor = scope.ServiceProvider.GetRequiredService(); - var session = await sessionAccessor.GetSessionAsync(CancellationToken.None); - - var tokenId = $"election-token-{Guid.NewGuid():N}"; - var document = new AuthorityTokenDocument - { - TokenId = tokenId, - Type = OpenIddictConstants.TokenTypeHints.AccessToken, - SubjectId = "session-subject", - ClientId = "session-client", - Scope = new List { "jobs:read" }, - Status = "valid", - CreatedAt = clock.GetUtcNow(), - ExpiresAt = clock.GetUtcNow().AddMinutes(30) - }; - - await tokenStore.InsertAsync(document, CancellationToken.None, session); - - await StepDownPrimaryAsync(fixture.Client, CancellationToken.None); - - AuthorityTokenDocument? fetched = null; - for (var attempt = 0; attempt < 5; attempt++) - { - try - { - fetched = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session); - if (fetched is not null) - { - break; - } - } - catch (MongoException) - { - await Task.Delay(250); - } - } - - Assert.NotNull(fetched); - Assert.Equal(tokenId, fetched!.TokenId); - } - - private static async Task StepDownPrimaryAsync(IMongoClient client, CancellationToken cancellationToken) - { - var admin = client.GetDatabase("admin"); - try - { - var command = new BsonDocument - { - { "replSetStepDown", 5 }, - { "force", true } - }; - - await admin.RunCommandAsync(command, cancellationToken: cancellationToken); - } - catch (MongoCommandException) - { - // Expected when the current primary steps down. - } - catch (MongoConnectionException) - { - // Connection may drop during election; ignore and continue. - } - - await WaitForPrimaryAsync(admin, cancellationToken); - } - - private static async Task WaitForPrimaryAsync(IMongoDatabase adminDatabase, CancellationToken cancellationToken) - { - for (var attempt = 0; attempt < 40; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var status = await adminDatabase.RunCommandAsync(new BsonDocument { { "replSetGetStatus", 1 } }, cancellationToken: cancellationToken); - if (status.TryGetValue("myState", out var state) && state.ToInt32() == 1) - { - return; - } - } - catch (MongoCommandException) - { - // Ignore intermediate states and retry. - } - - await Task.Delay(250, cancellationToken); - } - - throw new TimeoutException("Replica set primary election did not complete in time."); - } - - private async Task ResetCollectionsAsync() - { - var tokens = fixture.Database.GetCollection(AuthorityMongoDefaults.Collections.Tokens); - await tokens.DeleteManyAsync(Builders.Filter.Empty); - - var clients = fixture.Database.GetCollection(AuthorityMongoDefaults.Collections.Clients); - await clients.DeleteManyAsync(Builders.Filter.Empty); - } - - private async Task BuildMongoProviderAsync(FakeTimeProvider clock) - { - var services = new ServiceCollection(); - services.AddSingleton(clock); - services.AddLogging(); - services.AddAuthorityMongoStorage(options => - { - options.ConnectionString = fixture.Runner.ConnectionString; - options.DatabaseName = fixture.Database.DatabaseNamespace.DatabaseName; - options.CommandTimeout = TimeSpan.FromSeconds(5); - }); - - var provider = services.BuildServiceProvider(); - - var initializer = provider.GetRequiredService(); - var database = provider.GetRequiredService(); - await initializer.InitialiseAsync(database, CancellationToken.None); - - return provider; - } -} +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using StellaOps.Authority.OpenIddict.Handlers; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using Xunit; + +namespace StellaOps.Authority.Tests.OpenIddict; + +public sealed class TokenPersistenceIntegrationTests +{ + private static readonly ActivitySource Activity = new("StellaOps.Authority.Tests.Persistence"); + + [Fact] + public async Task PersistTokensHandler_StoresAccessTokenMetadata() + { + var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero); + var clock = new FakeTimeProvider(issuedAt); + var tokenStore = new InMemoryTokenStore(); + var handler = new PersistTokensHandler(tokenStore, new NullAuthorityMongoSessionAccessor(), clock, Activity, NullLogger.Instance); + + var identity = new ClaimsIdentity(authenticationType: "test"); + identity.SetClaim(OpenIddictConstants.Claims.Subject, "subject-1"); + identity.SetClaim(OpenIddictConstants.Claims.ClientId, "client-1"); + identity.SetScopes("jobs:trigger"); + var principal = new ClaimsPrincipal(identity); + + var transaction = new OpenIddictServerTransaction + { + Request = new OpenIddictRequest(), + Options = new OpenIddictServerOptions() + }; + + var context = new OpenIddictServerEvents.ProcessSignInContext(transaction) + { + Principal = principal, + AccessTokenPrincipal = principal + }; + + await handler.HandleAsync(context); + + var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); + var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None); + + Assert.NotNull(stored); + Assert.Equal(OpenIddictConstants.TokenTypeHints.AccessToken, stored!.TokenType); + Assert.Equal("valid", stored.Status); + Assert.Equal(issuedAt, stored.CreatedAt); + Assert.Contains("jobs:trigger", stored.Scope); + } + + [Fact] + public async Task RecordUsageAsync_FlagsReplayOnNewFingerprint() + { + var tokenStore = new InMemoryTokenStore(); + var token = new AuthorityTokenDocument + { + TokenId = "token-replay", + TokenType = OpenIddictConstants.TokenTypeHints.AccessToken, + Status = "valid", + CreatedAt = DateTimeOffset.UtcNow + }; + + await tokenStore.InsertAsync(token, CancellationToken.None); + + var first = await tokenStore.RecordUsageAsync("token-replay", "10.0.0.1", "agent/1.0", DateTimeOffset.UtcNow, CancellationToken.None); + var second = await tokenStore.RecordUsageAsync("token-replay", "10.0.0.2", "agent/2.0", DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.Equal(TokenUsageUpdateStatus.Recorded, first.Status); + Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, second.Status); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Permalinks/VulnPermalinkServiceTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Permalinks/VulnPermalinkServiceTests.cs index c6c7c75b3..aa87d3760 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Permalinks/VulnPermalinkServiceTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Permalinks/VulnPermalinkServiceTests.cs @@ -1,118 +1,121 @@ -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Time.Testing; -using Microsoft.IdentityModel.Tokens; -using StellaOps.Authority.Permalinks; -using StellaOps.Authority.Signing; -using StellaOps.Configuration; -using StellaOps.Cryptography; -using StellaOps.Cryptography.DependencyInjection; -using StellaOps.Auth.Abstractions; -using Xunit; - +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Microsoft.IdentityModel.Tokens; +using StellaOps.Authority.Permalinks; +using StellaOps.Authority.Signing; +using StellaOps.Configuration; +using StellaOps.Cryptography; +using StellaOps.Cryptography.DependencyInjection; +using StellaOps.Auth.Abstractions; +using Xunit; + namespace StellaOps.Authority.Tests.Permalinks; #pragma warning disable CS0618 // legacy scope coverage public sealed class VulnPermalinkServiceTests -{ - [Fact] - public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims() - { - var tempDir = Directory.CreateTempSubdirectory("authority-permalink-tests").FullName; - var keyRelative = "permalink.pem"; - try - { - CreateEcPrivateKey(Path.Combine(tempDir, keyRelative)); - - var options = new StellaOpsAuthorityOptions - { - Issuer = new Uri("https://authority.test"), - Storage = { ConnectionString = "mongodb://localhost/test" }, - Signing = - { - Enabled = true, - ActiveKeyId = "permalink-key", - KeyPath = keyRelative, - Algorithm = SignatureAlgorithms.Es256, - KeySource = "file", - Provider = "default" - } - }; - - var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-26T12:00:00Z")); - - using var provider = BuildProvider(tempDir, options, fakeTime); - // Ensure signing keys are loaded - provider.GetRequiredService(); - - var service = provider.GetRequiredService(); - var state = JsonDocument.Parse("{\"vulnId\":\"CVE-2025-1234\"}").RootElement; - var request = new VulnPermalinkRequest( - Tenant: "tenant-a", - ResourceKind: "vulnerability", - State: state, - ExpiresInSeconds: null, - Environment: "prod"); - - var expectedNow = fakeTime.GetUtcNow(); - - var response = await service.CreateAsync(request, default); - - Assert.NotNull(response.Token); - Assert.Equal(expectedNow, response.IssuedAt); - Assert.Equal(expectedNow.AddHours(24), response.ExpiresAt); - Assert.Contains(StellaOpsScopes.VulnRead, response.Scopes); - - var parts = response.Token.Split('.'); - Assert.Equal(3, parts.Length); - - var payloadBytes = Base64UrlEncoder.DecodeBytes(parts[1]); - using var payloadDocument = JsonDocument.Parse(payloadBytes); - var payload = payloadDocument.RootElement; - - Assert.Equal("vulnerability", payload.GetProperty("type").GetString()); - Assert.Equal("tenant-a", payload.GetProperty("tenant").GetString()); - Assert.Equal("prod", payload.GetProperty("environment").GetString()); - Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("iat").GetInt64()); - Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("nbf").GetInt64()); - Assert.Equal(expectedNow.AddHours(24).ToUnixTimeSeconds(), payload.GetProperty("exp").GetInt64()); - - var scopes = payload.GetProperty("scopes").EnumerateArray().Select(element => element.GetString()).ToArray(); - Assert.Contains(StellaOpsScopes.VulnRead, scopes); - - var resource = payload.GetProperty("resource"); - Assert.Equal("vulnerability", resource.GetProperty("kind").GetString()); - Assert.Equal("CVE-2025-1234", resource.GetProperty("state").GetProperty("vulnId").GetString()); - } - finally - { - try - { - Directory.Delete(tempDir, recursive: true); - } - catch - { - // ignore cleanup failures - } - } - } - - private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider) - { +{ + [Fact] + public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims() + { + var tempDir = Directory.CreateTempSubdirectory("authority-permalink-tests").FullName; + var keyRelative = "permalink.pem"; + try + { + CreateEcPrivateKey(Path.Combine(tempDir, keyRelative)); + + var options = new StellaOpsAuthorityOptions + { + Issuer = new Uri("https://authority.test"), + Storage = { ConnectionString = "mongodb://localhost/test" }, + Signing = + { + Enabled = true, + ActiveKeyId = "permalink-key", + KeyPath = keyRelative, + Algorithm = SignatureAlgorithms.Es256, + KeySource = "file", + Provider = "default" + } + }; + + var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-26T12:00:00Z")); + + using var provider = BuildProvider(tempDir, options, fakeTime); + // Ensure signing keys are loaded + provider.GetRequiredService(); + + var service = provider.GetRequiredService(); + var state = JsonDocument.Parse("{\"vulnId\":\"CVE-2025-1234\"}").RootElement; + var request = new VulnPermalinkRequest( + Tenant: "tenant-a", + ResourceKind: "vulnerability", + State: state, + ExpiresInSeconds: null, + Environment: "prod"); + + var expectedNow = fakeTime.GetUtcNow(); + + var response = await service.CreateAsync(request, default); + + Assert.NotNull(response.Token); + Assert.Equal(expectedNow, response.IssuedAt); + Assert.Equal(expectedNow.AddHours(24), response.ExpiresAt); + Assert.Contains(StellaOpsScopes.VulnRead, response.Scopes); + + var parts = response.Token.Split('.'); + Assert.Equal(3, parts.Length); + + var payloadBytes = Base64UrlEncoder.DecodeBytes(parts[1]); + using var payloadDocument = JsonDocument.Parse(payloadBytes); + var payload = payloadDocument.RootElement; + + Assert.Equal("vulnerability", payload.GetProperty("type").GetString()); + Assert.Equal("tenant-a", payload.GetProperty("tenant").GetString()); + Assert.Equal("prod", payload.GetProperty("environment").GetString()); + Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("iat").GetInt64()); + Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("nbf").GetInt64()); + Assert.Equal(expectedNow.AddHours(24).ToUnixTimeSeconds(), payload.GetProperty("exp").GetInt64()); + + var scopes = payload.GetProperty("scopes").EnumerateArray().Select(element => element.GetString()).ToArray(); + Assert.Contains(StellaOpsScopes.VulnRead, scopes); + + var resource = payload.GetProperty("resource"); + Assert.Equal("vulnerability", resource.GetProperty("kind").GetString()); + Assert.Equal("CVE-2025-1234", resource.GetProperty("state").GetProperty("vulnId").GetString()); + } + finally + { + try + { + Directory.Delete(tempDir, recursive: true); + } + catch + { + // ignore cleanup failures + } + } + } + + private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider) + { var services = new ServiceCollection(); services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.AddSingleton(new TestHostEnvironment(basePath)); + services.AddSingleton(_ => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()); services.AddSingleton(options); services.AddSingleton>(Options.Create(options)); services.AddSingleton(timeProvider); @@ -125,32 +128,32 @@ public sealed class VulnPermalinkServiceTests return services.BuildServiceProvider(); } - - private static void CreateEcPrivateKey(string path) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - using var ecdsa = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256); - var pem = ecdsa.ExportECPrivateKeyPem(); - File.WriteAllText(path, pem); - } - - private sealed class TestHostEnvironment : IHostEnvironment - { - public TestHostEnvironment(string contentRoot) - { - ContentRootPath = contentRoot; - ContentRootFileProvider = new PhysicalFileProvider(contentRoot); - EnvironmentName = Environments.Development; - ApplicationName = "StellaOps.Authority.Tests"; - } - - public string EnvironmentName { get; set; } - - public string ApplicationName { get; set; } - - public string ContentRootPath { get; set; } - - public IFileProvider ContentRootFileProvider { get; set; } - } + + private static void CreateEcPrivateKey(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var ecdsa = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256); + var pem = ecdsa.ExportECPrivateKeyPem(); + File.WriteAllText(path, pem); + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public TestHostEnvironment(string contentRoot) + { + ContentRootPath = contentRoot; + ContentRootFileProvider = new PhysicalFileProvider(contentRoot); + EnvironmentName = Environments.Development; + ApplicationName = "StellaOps.Authority.Tests"; + } + + public string EnvironmentName { get; set; } + + public string ApplicationName { get; set; } + + public string ContentRootPath { get; set; } + + public IFileProvider ContentRootFileProvider { get; set; } + } } #pragma warning restore CS0618 diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs index 1ecd97087..55be8f61a 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthorityJwksServiceTests.cs @@ -162,6 +162,12 @@ public sealed class AuthorityJwksServiceTests var provider = providers.First(); return new CryptoSignerResolution(provider.GetSigner(algorithmId, keyReference), provider.Name); } + + public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null) + { + var provider = providers.First(); + return new CryptoHasherResolution(provider.GetHasher(algorithmId), provider.Name); + } } private sealed class TestCryptoProvider : CryptoProvider @@ -182,6 +188,8 @@ public sealed class AuthorityJwksServiceTests public IPasswordHasher GetPasswordHasher(string algorithmId) => throw new NotSupportedException(); + public ICryptoHasher GetHasher(string algorithmId) => new TestHasher(algorithmId); + public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) { if (!keys.TryGetValue(keyReference.KeyId, out var key)) @@ -194,7 +202,12 @@ public sealed class AuthorityJwksServiceTests public void UpsertSigningKey(CryptoSigningKey signingKey) { - keys[signingKey.Reference.KeyId] = new TestKey(signingKey.Reference.KeyId, signingKey.PublicParameters); + keys[signingKey.Reference.KeyId] = new TestKey( + signingKey.Reference.KeyId, + signingKey.PrivateParameters, + signingKey.AlgorithmId, + signingKey.PrivateKey.ToArray(), + signingKey.PublicKey.ToArray()); } public bool RemoveSigningKey(string keyId) => keys.Remove(keyId); @@ -211,7 +224,7 @@ public sealed class AuthorityJwksServiceTests { using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); var parameters = ecdsa.ExportParameters(true); - keys[keyId] = new TestKey(keyId, parameters); + keys[keyId] = new TestKey(keyId, parameters, SignatureAlgorithms.Es256); } public void AddSm2Key(string keyId) @@ -219,38 +232,94 @@ public sealed class AuthorityJwksServiceTests var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1"); var domain = new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed()); var generator = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("EC"); - generator.Init(new Org.BouncyCastle.Crypto.Generators.ECKeyGenerationParameters(domain, new Org.BouncyCastle.Security.SecureRandom())); + generator.Init(new Org.BouncyCastle.Crypto.Parameters.ECKeyGenerationParameters(domain, new Org.BouncyCastle.Security.SecureRandom())); var pair = generator.GenerateKeyPair(); - var privateDer = Org.BouncyCastle.Asn1.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(pair.Private).GetDerEncoded(); + var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(pair.Private).GetDerEncoded(); + var publicDer = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(pair.Public).GetDerEncoded(); + var publicParams = (Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters)pair.Public; + var q = publicParams.Q.Normalize(); + var parameters = new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint + { + X = q.AffineXCoord.GetEncoded(), + Y = q.AffineYCoord.GetEncoded() + } + }; var keyRef = new CryptoKeyReference(keyId); var signingKey = new CryptoSigningKey(keyRef, SignatureAlgorithms.Sm2, privateDer, DateTimeOffset.UtcNow); - keys[keyId] = new TestKey(keyId, signingKey.PublicParameters); + keys[keyId] = new TestKey(keyId, parameters, SignatureAlgorithms.Sm2, privateDer, publicDer); + } + + private sealed class TestHasher : ICryptoHasher + { + public TestHasher(string algorithmId) + { + AlgorithmId = algorithmId; + } + + public string AlgorithmId { get; } + + public byte[] ComputeHash(ReadOnlySpan data) + { + using var sha = SHA256.Create(); + return sha.ComputeHash(data.ToArray()); + } + + public string ComputeHashHex(ReadOnlySpan data) + { + var hash = ComputeHash(data); + return Convert.ToHexString(hash).ToLowerInvariant(); + } } private sealed class TestKey { - public TestKey(string keyId, ECParameters parameters) + public TestKey(string keyId, ECParameters parameters, string algorithmId, byte[]? privateKeyBytes = null, byte[]? publicKeyBytes = null) { KeyId = keyId; Parameters = parameters; + AlgorithmId = algorithmId; + PrivateKeyBytes = privateKeyBytes; + PublicKeyBytes = publicKeyBytes; } public string KeyId { get; } public ECParameters Parameters { get; } + public string AlgorithmId { get; } + + public byte[]? PrivateKeyBytes { get; } + + public byte[]? PublicKeyBytes { get; } + public CryptoSigningKey ToSigningKey() { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["status"] = AuthoritySigningKeyStatus.Active + }; + + if (PrivateKeyBytes is { Length: > 0 }) + { + return new CryptoSigningKey( + new CryptoKeyReference(KeyId, "test"), + AlgorithmId, + PrivateKeyBytes, + DateTimeOffset.UtcNow, + metadata: metadata, + publicKey: PublicKeyBytes); + } + var ecParameters = Parameters; return new CryptoSigningKey( new CryptoKeyReference(KeyId, "test"), - SignatureAlgorithms.Es256, + AlgorithmId, in ecParameters, DateTimeOffset.UtcNow, - metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["status"] = AuthoritySigningKeyStatus.Active - }); + metadata: metadata); } } } @@ -287,7 +356,7 @@ public sealed class AuthorityJwksServiceTests Alg = AlgorithmId, Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve, Use = JsonWebKeyUseNames.Sig, - Crv = JsonWebKeyECTypes.P256, + Crv = AlgorithmId == SignatureAlgorithms.Sm2 ? "SM2" : JsonWebKeyECTypes.P256, X = Base64UrlEncoder.Encode(x), Y = Base64UrlEncoder.Encode(y) }; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthoritySigningKeyManagerTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthoritySigningKeyManagerTests.cs index 9eaf9b425..59a5de5a4 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthoritySigningKeyManagerTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Signing/AuthoritySigningKeyManagerTests.cs @@ -2,6 +2,8 @@ using System; using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; @@ -98,6 +100,7 @@ public sealed class AuthoritySigningKeyManagerTests var services = new ServiceCollection(); services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.AddSingleton(new TestHostEnvironment(basePath)); + services.AddSingleton(_ => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()); services.AddSingleton(options); services.AddSingleton>(Options.Create(options)); services.AddSingleton(TimeProvider.System); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs index 4da284f86..f3bf00dc9 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs @@ -13,7 +13,6 @@ using OpenIddict.Abstractions; using OpenIddict.Extensions; using OpenIddict.Server; using OpenIddict.Server.AspNetCore; -using MongoDB.Driver; using StellaOps.Auth.Abstractions; using StellaOps.Authority.Airgap; using StellaOps.Authority.OpenIddict; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs index c97083cac..7c247e836 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/TokenPersistenceHandlers.cs @@ -1,91 +1,90 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using System.Security.Claims; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using OpenIddict.Abstractions; -using OpenIddict.Extensions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; +using OpenIddict.Extensions; using OpenIddict.Server; -using MongoDB.Driver; using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Sessions; using StellaOps.Authority.Storage.Mongo.Stores; using StellaOps.Auth.Abstractions; - -namespace StellaOps.Authority.OpenIddict.Handlers; - -internal sealed class PersistTokensHandler : IOpenIddictServerHandler -{ - private readonly IAuthorityTokenStore tokenStore; - private readonly IAuthorityMongoSessionAccessor sessionAccessor; - private readonly TimeProvider clock; - private readonly ActivitySource activitySource; - private readonly ILogger logger; - - public PersistTokensHandler( - IAuthorityTokenStore tokenStore, - IAuthorityMongoSessionAccessor sessionAccessor, - TimeProvider clock, - ActivitySource activitySource, - ILogger logger) - { - this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); - this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor)); - this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); - this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context) - { - ArgumentNullException.ThrowIfNull(context); - - if (context.AccessTokenPrincipal is null && - context.RefreshTokenPrincipal is null && - context.AuthorizationCodePrincipal is null && - context.DeviceCodePrincipal is null) - { - return; - } - - using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal); - var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false); - var issuedAt = clock.GetUtcNow(); - - if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal) - { - await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false); - } - - if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal) - { - await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false); - } - - if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal) - { - await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false); - } - - if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal) - { - await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false); - } - } - - private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken) - { - var tokenId = EnsureTokenId(principal); - var scopes = ExtractScopes(principal); + +namespace StellaOps.Authority.OpenIddict.Handlers; + +internal sealed class PersistTokensHandler : IOpenIddictServerHandler +{ + private readonly IAuthorityTokenStore tokenStore; + private readonly IAuthorityMongoSessionAccessor sessionAccessor; + private readonly TimeProvider clock; + private readonly ActivitySource activitySource; + private readonly ILogger logger; + + public PersistTokensHandler( + IAuthorityTokenStore tokenStore, + IAuthorityMongoSessionAccessor sessionAccessor, + TimeProvider clock, + ActivitySource activitySource, + ILogger logger) + { + this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); + this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor)); + this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); + this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.AccessTokenPrincipal is null && + context.RefreshTokenPrincipal is null && + context.AuthorizationCodePrincipal is null && + context.DeviceCodePrincipal is null) + { + return; + } + + using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal); + var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false); + var issuedAt = clock.GetUtcNow(); + + if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal) + { + await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false); + } + + if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal) + { + await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false); + } + + if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal) + { + await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false); + } + + if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal) + { + await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false); + } + } + + private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken) + { + var tokenId = EnsureTokenId(principal); + var scopes = ExtractScopes(principal); var document = new AuthorityTokenDocument { TokenId = tokenId, - Type = tokenType, + TokenType = tokenType, SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject), ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId), Scope = scopes, @@ -172,50 +171,50 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler"); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId); - } - } - - private static string EnsureTokenId(ClaimsPrincipal principal) - { - var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); - if (string.IsNullOrWhiteSpace(tokenId)) - { - tokenId = Guid.NewGuid().ToString("N"); - principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId); - } - - return tokenId; - } - + var confirmation = principal.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType); + if (!string.IsNullOrWhiteSpace(confirmation)) + { + try + { + using var json = JsonDocument.Parse(confirmation); + if (json.RootElement.TryGetProperty("jkt", out var thumbprintElement)) + { + document.SenderKeyThumbprint = thumbprintElement.GetString(); + } + else if (json.RootElement.TryGetProperty("x5t#S256", out var certificateThumbprintElement)) + { + document.SenderKeyThumbprint = certificateThumbprintElement.GetString(); + } + } + catch (JsonException) + { + // Ignore malformed confirmation claims in persistence layer. + } + } + + try + { + await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false); + logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? ""); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId); + } + } + + private static string EnsureTokenId(ClaimsPrincipal principal) + { + var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); + if (string.IsNullOrWhiteSpace(tokenId)) + { + tokenId = Guid.NewGuid().ToString("N"); + principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId); + } + + return tokenId; + } + private static List ExtractScopes(ClaimsPrincipal principal) => principal.GetScopes() .Where(scope => !string.IsNullOrWhiteSpace(scope)) @@ -265,20 +264,20 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler +builder.Services.AddAuthorityPostgresStorage(options => { - storageOptions.ConnectionString = authorityOptions.Storage.ConnectionString; - storageOptions.DatabaseName = authorityOptions.Storage.DatabaseName; - storageOptions.CommandTimeout = authorityOptions.Storage.CommandTimeout; + options.ConnectionString = authorityOptions.Storage.ConnectionString; + options.CommandTimeoutSeconds = (int)authorityOptions.Storage.CommandTimeout.TotalSeconds; + options.SchemaName = "authority"; + options.AutoMigrate = true; + options.MigrationsPath = "Migrations"; }); +builder.Services.TryAddSingleton(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -399,10 +412,6 @@ builder.Services.Configure(options => var app = builder.Build(); -// Initialize storage (Mongo shim delegates to PostgreSQL migrations) -var mongoInitializer = app.Services.GetRequiredService(); -await mongoInitializer.InitialiseAsync(null!, CancellationToken.None); - var serviceAccountStore = app.Services.GetRequiredService(); if (authorityOptions.Delegation.ServiceAccounts.Count > 0) { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Revocation/RevocationBundleBuilder.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Revocation/RevocationBundleBuilder.cs index a05be925a..d2f036687 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Revocation/RevocationBundleBuilder.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Revocation/RevocationBundleBuilder.cs @@ -140,7 +140,7 @@ internal sealed class RevocationBundleBuilder var metadata = document.RevokedMetadata is null ? null - : new SortedDictionary(document.RevokedMetadata, StringComparer.OrdinalIgnoreCase); + : new SortedDictionary(document.RevokedMetadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase); yield return new RevocationEntryModel { diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresAirgapAuditStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresAirgapAuditStore.cs new file mode 100644 index 000000000..553658785 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresAirgapAuditStore.cs @@ -0,0 +1,141 @@ +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of . +/// +internal sealed class PostgresAirgapAuditStore : IAuthorityAirgapAuditStore +{ + private readonly AirgapAuditRepository repository; + + public PostgresAirgapAuditStore(AirgapAuditRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var properties = new List(); + void AddProperty(string name, string? value) + { + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(value)) + { + properties.Add(new AirgapAuditPropertyEntity { Name = name, Value = value }); + } + } + + foreach (var property in document.Properties) + { + AddProperty(property.Name, property.Value); + } + + AddProperty("tenant", document.Tenant); + AddProperty("subject_id", document.SubjectId); + AddProperty("username", document.Username); + AddProperty("display_name", document.DisplayName); + AddProperty("client_id", document.ClientId); + AddProperty("bundle_id", document.BundleId); + AddProperty("status", document.Status); + AddProperty("trace_id", document.TraceId); + + var entity = new AirgapAuditEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + EventType = string.IsNullOrWhiteSpace(document.EventType) ? "audit" : document.EventType, + OperatorId = document.OperatorId, + ComponentId = document.ComponentId, + Outcome = string.IsNullOrWhiteSpace(document.Status) ? document.Outcome : document.Status!, + Reason = document.Reason, + OccurredAt = document.OccurredAt, + Properties = properties + }; + + await repository.InsertAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(limit, offset, cancellationToken).ConfigureAwait(false); + return items.Select(Map).ToArray(); + } + + public async ValueTask QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + ArgumentNullException.ThrowIfNull(query); + + var take = query.Limit <= 0 ? 50 : query.Limit; + var items = await repository.ListAsync(take + 1, 0, cancellationToken).ConfigureAwait(false); + var documents = items.Select(Map).AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(query.Tenant)) + { + documents = documents.Where(d => string.Equals(d.Tenant, query.Tenant, StringComparison.Ordinal)); + } + + if (!string.IsNullOrWhiteSpace(query.BundleId)) + { + documents = documents.Where(d => string.Equals(d.BundleId, query.BundleId, StringComparison.Ordinal)); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + documents = documents.Where(d => string.Equals(d.Status, query.Status, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(query.TraceId)) + { + documents = documents.Where(d => string.Equals(d.TraceId, query.TraceId, StringComparison.OrdinalIgnoreCase)); + } + + documents = documents.OrderByDescending(d => d.OccurredAt).ThenBy(d => d.Id, StringComparer.Ordinal); + + if (!string.IsNullOrWhiteSpace(query.AfterId)) + { + documents = documents.SkipWhile(d => !string.Equals(d.Id, query.AfterId, StringComparison.Ordinal)).Skip(1); + } + + var page = documents.Take(take + 1).ToList(); + var next = page.Count > take ? page[^1].Id : null; + if (page.Count > take) + { + page.RemoveAt(page.Count - 1); + } + + return new AuthorityAirgapAuditQueryResult(page, next); + } + + private static AuthorityAirgapAuditDocument Map(AirgapAuditEntity entity) => new() + { + Id = entity.Id, + EventType = entity.EventType, + OperatorId = entity.OperatorId, + ComponentId = entity.ComponentId, + Outcome = entity.Outcome, + Reason = entity.Reason, + OccurredAt = entity.OccurredAt, + Status = entity.Outcome, + Tenant = Get(entity.Properties, "tenant"), + SubjectId = Get(entity.Properties, "subject_id"), + Username = Get(entity.Properties, "username"), + DisplayName = Get(entity.Properties, "display_name"), + ClientId = Get(entity.Properties, "client_id"), + BundleId = Get(entity.Properties, "bundle_id"), + TraceId = Get(entity.Properties, "trace_id"), + Properties = entity.Properties.Select(p => new AuthorityAirgapAuditPropertyDocument + { + Name = p.Name, + Value = p.Value + }).ToList() + }; + + private static string? Get(IEnumerable properties, string name) + { + var property = properties.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + return property?.Value; + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresBootstrapInviteStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresBootstrapInviteStore.cs new file mode 100644 index 000000000..951d94854 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresBootstrapInviteStore.cs @@ -0,0 +1,111 @@ +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of . +/// +internal sealed class PostgresBootstrapInviteStore : IAuthorityBootstrapInviteStore +{ + private readonly BootstrapInviteRepository repository; + + public PostgresBootstrapInviteStore(BootstrapInviteRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.FindByTokenAsync(token, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new BootstrapInviteEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + Token = document.Token, + Type = document.Type, + Provider = document.Provider, + Target = document.Target, + ExpiresAt = document.ExpiresAt, + CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt, + IssuedAt = document.IssuedAt == default ? document.CreatedAt : document.IssuedAt, + IssuedBy = document.IssuedBy, + ReservedUntil = document.ReservedUntil, + ReservedBy = document.ReservedBy, + Consumed = document.Consumed, + Status = string.IsNullOrWhiteSpace(document.Status) ? AuthorityBootstrapInviteStatuses.Pending : document.Status, + Metadata = document.Metadata is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(document.Metadata, StringComparer.OrdinalIgnoreCase) + }; + await repository.InsertAsync(entity, cancellationToken).ConfigureAwait(false); + return Map(entity); + } + + public async ValueTask TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var invite = await FindByTokenAsync(token, cancellationToken, session).ConfigureAwait(false); + if (invite is null || !string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase)) + { + return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite); + } + + if (invite.ExpiresAt <= now) + { + return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite); + } + + if (invite.Consumed || string.Equals(invite.Status, AuthorityBootstrapInviteStatuses.Consumed, StringComparison.OrdinalIgnoreCase)) + { + return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.AlreadyUsed, invite); + } + + var reserved = await repository.TryReserveAsync(token, expectedType, now, reservedBy, cancellationToken).ConfigureAwait(false); + if (!reserved) + { + return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.AlreadyUsed, invite); + } + + invite.Status = AuthorityBootstrapInviteStatuses.Reserved; + invite.ReservedBy = reservedBy; + invite.ReservedUntil = now.AddMinutes(15); + return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite); + } + + public async ValueTask ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => await repository.ReleaseAsync(token, cancellationToken).ConfigureAwait(false); + + public async ValueTask MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => await repository.ConsumeAsync(token, consumedBy, consumedAt, cancellationToken).ConfigureAwait(false); + + public async ValueTask> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var expired = await repository.ExpireAsync(asOf, cancellationToken).ConfigureAwait(false); + return expired.Select(Map).ToArray(); + } + + private static AuthorityBootstrapInviteDocument Map(BootstrapInviteEntity entity) => new() + { + Id = entity.Id, + Token = entity.Token, + Type = entity.Type, + Provider = entity.Provider, + Target = entity.Target, + ExpiresAt = entity.ExpiresAt, + CreatedAt = entity.CreatedAt, + IssuedAt = entity.IssuedAt, + IssuedBy = entity.IssuedBy, + ReservedUntil = entity.ReservedUntil, + ReservedBy = entity.ReservedBy, + Consumed = entity.Consumed, + Status = string.IsNullOrWhiteSpace(entity.Status) ? AuthorityBootstrapInviteStatuses.Pending : entity.Status, + Metadata = new Dictionary(entity.Metadata, StringComparer.OrdinalIgnoreCase) + }; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresClientStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresClientStore.cs new file mode 100644 index 000000000..5050e7d6d --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresClientStore.cs @@ -0,0 +1,115 @@ +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of . +/// +internal sealed class PostgresClientStore : IAuthorityClientStore +{ + private readonly ClientRepository repository; + + public PostgresClientStore(ClientRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new ClientEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + ClientId = document.ClientId, + ClientSecret = document.ClientSecret, + SecretHash = document.SecretHash, + DisplayName = document.DisplayName, + Description = document.Description, + Plugin = document.Plugin, + SenderConstraint = document.SenderConstraint, + Enabled = document.Enabled && !document.Disabled, + RedirectUris = document.RedirectUris, + PostLogoutRedirectUris = document.PostLogoutRedirectUris, + AllowedScopes = document.AllowedScopes, + AllowedGrantTypes = document.AllowedGrantTypes, + RequireClientSecret = document.RequireClientSecret, + RequirePkce = document.RequirePkce, + AllowPlainTextPkce = document.AllowPlainTextPkce, + ClientType = document.ClientType, + Properties = document.Properties, + CertificateBindings = document.CertificateBindings.Select(MapBinding).ToArray(), + CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt, + UpdatedAt = document.UpdatedAt == default ? DateTimeOffset.UtcNow : document.UpdatedAt + }; + + await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + return await repository.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false); + } + + private static AuthorityClientDocument Map(ClientEntity entity) => new() + { + Id = entity.Id, + ClientId = entity.ClientId, + ClientSecret = entity.ClientSecret, + SecretHash = entity.SecretHash, + DisplayName = entity.DisplayName, + Description = entity.Description, + Plugin = entity.Plugin, + SenderConstraint = entity.SenderConstraint, + Enabled = entity.Enabled, + Disabled = !entity.Enabled, + RedirectUris = entity.RedirectUris.ToList(), + PostLogoutRedirectUris = entity.PostLogoutRedirectUris.ToList(), + AllowedScopes = entity.AllowedScopes.ToList(), + AllowedGrantTypes = entity.AllowedGrantTypes.ToList(), + RequireClientSecret = entity.RequireClientSecret, + RequirePkce = entity.RequirePkce, + AllowPlainTextPkce = entity.AllowPlainTextPkce, + ClientType = entity.ClientType, + Properties = entity.Properties.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase), + CertificateBindings = entity.CertificateBindings.Select(MapBinding).ToList(), + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + + private static ClientCertificateBindingEntity MapBinding(AuthorityClientCertificateBinding binding) => new() + { + Thumbprint = binding.Thumbprint, + SerialNumber = binding.SerialNumber, + Subject = binding.Subject, + Issuer = binding.Issuer, + SubjectAlternativeNames = binding.SubjectAlternativeNames, + NotBefore = binding.NotBefore, + NotAfter = binding.NotAfter, + Label = binding.Label, + CreatedAt = binding.CreatedAt, + UpdatedAt = binding.UpdatedAt + }; + + private static AuthorityClientCertificateBinding MapBinding(ClientCertificateBindingEntity entity) => new() + { + Thumbprint = entity.Thumbprint, + SerialNumber = entity.SerialNumber, + Subject = entity.Subject, + Issuer = entity.Issuer, + SubjectAlternativeNames = entity.SubjectAlternativeNames.ToList(), + NotBefore = entity.NotBefore, + NotAfter = entity.NotAfter, + Label = entity.Label, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresLoginAttemptStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresLoginAttemptStore.cs new file mode 100644 index 000000000..0f3ece616 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresLoginAttemptStore.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of . +/// +internal sealed class PostgresLoginAttemptStore : IAuthorityLoginAttemptStore +{ + private readonly LoginAttemptRepository repository; + + public PostgresLoginAttemptStore(LoginAttemptRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var properties = new List(); + void AddProperty(string name, string? value, bool sensitive = false, string classification = "none") + { + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(value)) + { + properties.Add(new LoginAttemptPropertyEntity + { + Name = name, + Value = value, + Sensitive = sensitive, + Classification = classification + }); + } + } + + if (document.Properties is { Count: > 0 }) + { + foreach (var property in document.Properties) + { + AddProperty(property.Name, property.Value, property.Sensitive, property.Classification); + } + } + + AddProperty("tenant", document.Tenant); + AddProperty("username", document.Username); + AddProperty("plugin", document.Plugin); + AddProperty("correlation_id", document.CorrelationId); + AddProperty("remote_address", document.RemoteAddress ?? document.IpAddress); + AddProperty("successful", document.Successful.ToString(CultureInfo.InvariantCulture)); + + if (document.Scopes.Count > 0) + { + AddProperty("scopes", string.Join(' ', document.Scopes)); + } + + var entity = new LoginAttemptEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + SubjectId = document.SubjectId, + ClientId = document.ClientId, + EventType = document.EventType, + Outcome = document.Outcome, + Reason = document.Reason, + IpAddress = document.RemoteAddress ?? document.IpAddress, + UserAgent = document.UserAgent, + OccurredAt = document.OccurredAt, + Properties = properties + }; + + await repository.InsertAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListRecentAsync(subjectId, limit, cancellationToken).ConfigureAwait(false); + return items.Select(Map).ToArray(); + } + + private static AuthorityLoginAttemptDocument Map(LoginAttemptEntity entity) + { + var propertyBag = entity.Properties.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase); + + return new AuthorityLoginAttemptDocument + { + Id = entity.Id, + CorrelationId = Get(propertyBag, "correlation_id"), + SubjectId = entity.SubjectId, + Username = Get(propertyBag, "username"), + ClientId = entity.ClientId, + Plugin = Get(propertyBag, "plugin"), + EventType = entity.EventType, + Outcome = entity.Outcome, + Successful = bool.TryParse(Get(propertyBag, "successful"), out var success) + ? success + : string.Equals(entity.Outcome, "success", StringComparison.OrdinalIgnoreCase), + Reason = entity.Reason, + RemoteAddress = Get(propertyBag, "remote_address") ?? entity.IpAddress, + IpAddress = entity.IpAddress, + UserAgent = entity.UserAgent, + Tenant = Get(propertyBag, "tenant"), + Scopes = ParseScopes(Get(propertyBag, "scopes")), + OccurredAt = entity.OccurredAt, + Properties = entity.Properties.Select(p => new AuthorityLoginAttemptPropertyDocument + { + Name = p.Name, + Value = p.Value, + Sensitive = p.Sensitive, + Classification = string.IsNullOrWhiteSpace(p.Classification) ? "none" : p.Classification + }).ToList() + }; + } + + private static string? Get(IReadOnlyDictionary properties, string key) + => properties.TryGetValue(key, out var property) ? property.Value : null; + + private static List ParseScopes(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.Ordinal) + .OrderBy(scope => scope, StringComparer.Ordinal) + .ToList(); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresRevocationExportStateStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresRevocationExportStateStore.cs new file mode 100644 index 000000000..0769621ce --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresRevocationExportStateStore.cs @@ -0,0 +1,42 @@ +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +internal sealed class PostgresRevocationExportStateStore : IAuthorityRevocationExportStateStore +{ + private readonly RevocationExportStateRepository repository; + + public PostgresRevocationExportStateStore(RevocationExportStateRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.GetAsync(cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask UpdateAsync(long expectedSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new RevocationExportStateEntity + { + Sequence = newSequence, + BundleId = bundleId, + IssuedAt = issuedAt + }; + + await repository.UpsertAsync(expectedSequence, entity, cancellationToken).ConfigureAwait(false); + } + + private static AuthorityRevocationExportStateDocument Map(RevocationExportStateEntity entity) => new() + { + Sequence = entity.Sequence, + BundleId = entity.BundleId, + IssuedAt = entity.IssuedAt + }; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresRevocationStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresRevocationStore.cs new file mode 100644 index 000000000..801055f9d --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresRevocationStore.cs @@ -0,0 +1,68 @@ +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of . +/// +internal sealed class PostgresRevocationStore : IAuthorityRevocationStore +{ + private readonly RevocationRepository repository; + + public PostgresRevocationStore(RevocationRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new RevocationEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + Category = document.Category, + RevocationId = document.RevocationId, + SubjectId = document.SubjectId, + ClientId = document.ClientId, + TokenId = document.TokenId, + Reason = document.Reason, + ReasonDescription = document.ReasonDescription, + RevokedAt = document.RevokedAt, + EffectiveAt = document.EffectiveAt ?? document.RevokedAt, + ExpiresAt = document.ExpiresAt, + Metadata = document.Metadata + }; + + await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var records = await repository.GetActiveAsync(asOf, cancellationToken).ConfigureAwait(false); + return records.Select(Map).ToArray(); + } + + public async ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + await repository.RemoveAsync(category, revocationId, cancellationToken).ConfigureAwait(false); + } + + private static AuthorityRevocationDocument Map(RevocationEntity entity) => new() + { + Id = entity.Id, + Category = entity.Category, + RevocationId = entity.RevocationId, + SubjectId = entity.SubjectId, + ClientId = entity.ClientId, + TokenId = entity.TokenId, + Reason = entity.Reason, + ReasonDescription = entity.ReasonDescription, + RevokedAt = entity.RevokedAt, + EffectiveAt = entity.EffectiveAt, + ExpiresAt = entity.ExpiresAt, + Metadata = entity.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase) + }; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresServiceAccountStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresServiceAccountStore.cs new file mode 100644 index 000000000..7c2d2b9d5 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresServiceAccountStore.cs @@ -0,0 +1,78 @@ +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of . +/// +internal sealed class PostgresServiceAccountStore : IAuthorityServiceAccountStore +{ + private readonly ServiceAccountRepository repository; + + public PostgresServiceAccountStore(ServiceAccountRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.FindByAccountIdAsync(accountId, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(tenant, cancellationToken).ConfigureAwait(false); + return items.Select(Map).ToArray(); + } + + public async ValueTask> ListByTenantAsync(string tenant, CancellationToken cancellationToken = default, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(tenant, cancellationToken).ConfigureAwait(false); + return items.Select(Map).ToArray(); + } + + public async ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new ServiceAccountEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + AccountId = document.AccountId, + Tenant = document.Tenant, + DisplayName = document.DisplayName, + Description = document.Description, + Enabled = document.Enabled, + AllowedScopes = document.AllowedScopes, + AuthorizedClients = document.AuthorizedClients, + Attributes = document.Attributes, + CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt, + UpdatedAt = document.UpdatedAt == default ? DateTimeOffset.UtcNow : document.UpdatedAt + }; + + await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + return await repository.DeleteAsync(accountId, cancellationToken).ConfigureAwait(false); + } + + private static AuthorityServiceAccountDocument Map(ServiceAccountEntity entity) => new() + { + Id = entity.Id, + AccountId = entity.AccountId, + Tenant = entity.Tenant, + DisplayName = entity.DisplayName, + Description = entity.Description, + Enabled = entity.Enabled, + AllowedScopes = entity.AllowedScopes.ToList(), + AuthorizedClients = entity.AuthorizedClients.ToList(), + Attributes = entity.Attributes.ToDictionary(kv => kv.Key, kv => kv.Value.ToList(), StringComparer.OrdinalIgnoreCase), + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresTokenStore.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresTokenStore.cs new file mode 100644 index 000000000..cf8d66035 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Storage/Postgres/PostgresTokenStore.cs @@ -0,0 +1,464 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using StellaOps.Authority.Storage.Mongo.Documents; +using StellaOps.Authority.Storage.Mongo.Sessions; +using StellaOps.Authority.Storage.Mongo.Stores; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Authority.Storage.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.PostgresAdapters; + +/// +/// PostgreSQL-backed implementation of and . +/// +internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefreshTokenStore +{ + private readonly OidcTokenRepository repository; + private readonly ConcurrentDictionary> deviceFingerprints = new(StringComparer.OrdinalIgnoreCase); + + public PostgresTokenStore(OidcTokenRepository repository) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async ValueTask FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.FindByTokenIdAsync(tokenId, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.FindByReferenceIdAsync(referenceId, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListBySubjectAsync(subjectId, limit, cancellationToken).ConfigureAwait(false); + return items.Select(Map).ToArray(); + } + + public async ValueTask> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(Math.Max(limit * 2, limit), cancellationToken).ConfigureAwait(false); + var documents = items + .Select(Map) + .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, scope, StringComparison.Ordinal))) + .OrderByDescending(t => t.CreatedAt) + .Take(limit) + .ToArray(); + + return documents; + } + + public async ValueTask> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false); + var documents = items + .Select(Map) + .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) + .ToArray(); + + return documents; + } + + public async ValueTask CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var count = items + .Select(Map) + .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 count; + } + + public async ValueTask> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var documents = items + .Select(Map) + .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) + .ToArray(); + + return documents; + } + + public async ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new OidcTokenEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + TokenId = document.TokenId, + SubjectId = document.SubjectId, + ClientId = document.ClientId, + TokenType = string.IsNullOrWhiteSpace(document.TokenType) ? document.Type : document.TokenType, + ReferenceId = document.ReferenceId, + CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt, + ExpiresAt = document.ExpiresAt, + RedeemedAt = document.RedeemedAt, + Payload = document.Payload, + Properties = BuildProperties(document) + }; + + await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var existing = await FindByTokenIdAsync(tokenId, cancellationToken, session).ConfigureAwait(false); + if (existing is null) + { + return false; + } + + existing.Status = "revoked"; + existing.RevokedAt = DateTimeOffset.UtcNow; + await UpsertAsync(existing, cancellationToken, session).ConfigureAwait(false); + return true; + } + + public async ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var existing = await FindByTokenIdAsync(tokenId, cancellationToken, session).ConfigureAwait(false); + if (existing is null) + { + return; + } + + existing.Status = status; + existing.RevokedAt = revokedAt; + existing.RevokedReason = reason; + + await UpsertAsync(existing, cancellationToken, session).ConfigureAwait(false); + } + + public async ValueTask RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListAsync(500, cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var affected = 0; + + foreach (var doc in items.Select(Map).Where(t => string.Equals(t.ClientId, clientId, StringComparison.Ordinal))) + { + doc.Status = "revoked"; + doc.RevokedAt = now; + await UpsertAsync(doc, cancellationToken, session).ConfigureAwait(false); + affected++; + } + + return affected; + } + + public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + => UpsertAsync(document, cancellationToken, session); + + public ValueTask RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + if (string.IsNullOrWhiteSpace(tokenId)) + { + 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 key = tokenId.Trim(); + var fingerprint = $"{remoteAddress}|{userAgent}"; + var set = deviceFingerprints.GetOrAdd(key, static _ => new HashSet(StringComparer.Ordinal)); + var isNew = set.Add(fingerprint); + var status = isNew && set.Count > 1 ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded; + return ValueTask.FromResult(new TokenUsageUpdateResult(status, remoteAddress, userAgent)); + } + + async ValueTask IAuthorityRefreshTokenStore.FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session) + { + var entity = await repository.FindRefreshTokenAsync(tokenId, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask FindByHandleAsync(string handle, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = await repository.FindRefreshTokenByHandleAsync(handle, cancellationToken).ConfigureAwait(false); + return entity is null ? null : Map(entity); + } + + public async ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var entity = new OidcRefreshTokenEntity + { + Id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id, + TokenId = document.TokenId, + SubjectId = document.SubjectId, + ClientId = document.ClientId, + Handle = document.Handle, + CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt, + ExpiresAt = document.ExpiresAt, + ConsumedAt = document.ConsumedAt, + Payload = document.Payload + }; + + await repository.UpsertRefreshTokenAsync(entity, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask ConsumeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + return await repository.ConsumeRefreshTokenAsync(tokenId, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null) + { + var items = await repository.ListBySubjectAsync(subjectId, 200, cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var affected = 0; + + foreach (var doc in items.Select(Map)) + { + doc.Status = "revoked"; + doc.RevokedAt = now; + await UpsertAsync(doc, cancellationToken, session).ConfigureAwait(false); + affected++; + } + + await repository.RevokeRefreshTokensBySubjectAsync(subjectId, cancellationToken).ConfigureAwait(false); + return affected; + } + + private static AuthorityTokenDocument Map(OidcTokenEntity entity) + { + var properties = entity.Properties.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); + var scope = properties.TryGetValue("scope", out var scopeRaw) && !string.IsNullOrWhiteSpace(scopeRaw) + ? scopeRaw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList() + : new List(); + + List actorChain = new(); + if (properties.TryGetValue("actor_chain", out var actorJson) && !string.IsNullOrWhiteSpace(actorJson)) + { + try + { + actorChain = JsonSerializer.Deserialize>(actorJson) ?? new List(); + } + catch (JsonException) + { + actorChain = new List(); + } + } + + List devices = new(); + if (properties.TryGetValue("devices", out var devicesJson) && !string.IsNullOrWhiteSpace(devicesJson)) + { + try + { + devices = JsonSerializer.Deserialize>(devicesJson) ?? new List(); + } + catch (JsonException) + { + devices = new List(); + } + } + + properties.Remove("scope"); + properties.Remove("actor_chain"); + properties.Remove("devices"); + + return new AuthorityTokenDocument + { + Id = entity.Id, + TokenId = entity.TokenId, + SubjectId = entity.SubjectId, + ClientId = entity.ClientId, + TokenType = entity.TokenType, + ReferenceId = entity.ReferenceId, + CreatedAt = entity.CreatedAt, + ExpiresAt = entity.ExpiresAt, + RedeemedAt = entity.RedeemedAt, + Payload = entity.Payload, + Properties = properties, + Scope = scope, + ActorChain = actorChain, + Devices = devices, + Status = Get(properties, "status", "valid"), + Tenant = Get(properties, "tenant", null), + Project = Get(properties, "project", null), + SenderConstraint = Get(properties, "sender_constraint", null), + SenderNonce = Get(properties, "sender_nonce", null), + SenderCertificateHex = Get(properties, "sender_cert_hex", null), + SenderKeyThumbprint = Get(properties, "sender_key_thumbprint", null), + ServiceAccountId = Get(properties, "service_account_id", null), + TokenKind = Get(properties, "token_kind", null), + VulnerabilityEnvironment = Get(properties, "vuln_env", null), + VulnerabilityOwner = Get(properties, "vuln_owner", null), + VulnerabilityBusinessTier = Get(properties, "vuln_tier", null), + IncidentReason = Get(properties, "incident_reason", null), + RevokedAt = ParseDate(properties, "revoked_at"), + RevokedReason = Get(properties, "revoked_reason", null), + RevokedReasonDescription = Get(properties, "revoked_reason_desc", null), + RevokedMetadata = ExtractRevokedMetadata(properties) + }; + } + + private static Dictionary BuildProperties(AuthorityTokenDocument document) + { + var properties = new Dictionary(document.Properties, StringComparer.OrdinalIgnoreCase) + { + ["status"] = string.IsNullOrWhiteSpace(document.Status) ? "valid" : document.Status + }; + + if (!string.IsNullOrWhiteSpace(document.Tenant)) + { + properties["tenant"] = document.Tenant!; + } + + if (!string.IsNullOrWhiteSpace(document.Project)) + { + properties["project"] = document.Project!; + } + + if (!string.IsNullOrWhiteSpace(document.SenderConstraint)) + { + properties["sender_constraint"] = document.SenderConstraint!; + } + + if (!string.IsNullOrWhiteSpace(document.SenderNonce)) + { + properties["sender_nonce"] = document.SenderNonce!; + } + + if (!string.IsNullOrWhiteSpace(document.SenderCertificateHex)) + { + properties["sender_cert_hex"] = document.SenderCertificateHex!; + } + + if (!string.IsNullOrWhiteSpace(document.SenderKeyThumbprint)) + { + properties["sender_key_thumbprint"] = document.SenderKeyThumbprint!; + } + + if (!string.IsNullOrWhiteSpace(document.ServiceAccountId)) + { + properties["service_account_id"] = document.ServiceAccountId!; + } + + if (!string.IsNullOrWhiteSpace(document.TokenKind)) + { + properties["token_kind"] = document.TokenKind!; + } + + if (!string.IsNullOrWhiteSpace(document.VulnerabilityEnvironment)) + { + properties["vuln_env"] = document.VulnerabilityEnvironment!; + } + + if (!string.IsNullOrWhiteSpace(document.VulnerabilityOwner)) + { + properties["vuln_owner"] = document.VulnerabilityOwner!; + } + + if (!string.IsNullOrWhiteSpace(document.VulnerabilityBusinessTier)) + { + properties["vuln_tier"] = document.VulnerabilityBusinessTier!; + } + + if (!string.IsNullOrWhiteSpace(document.IncidentReason)) + { + properties["incident_reason"] = document.IncidentReason!; + } + + if (document.RevokedAt is not null) + { + properties["revoked_at"] = document.RevokedAt.Value.ToUniversalTime().ToString("O"); + } + + if (!string.IsNullOrWhiteSpace(document.RevokedReason)) + { + properties["revoked_reason"] = document.RevokedReason!; + } + + if (!string.IsNullOrWhiteSpace(document.RevokedReasonDescription)) + { + properties["revoked_reason_desc"] = document.RevokedReasonDescription!; + } + + if (document.RevokedMetadata is { Count: > 0 }) + { + foreach (var kvp in document.RevokedMetadata) + { + properties[$"revoked_meta_{kvp.Key}"] = kvp.Value ?? string.Empty; + } + } + + if (document.Scope.Count > 0) + { + properties["scope"] = string.Join(' ', document.Scope); + } + + if (document.ActorChain.Count > 0) + { + properties["actor_chain"] = JsonSerializer.Serialize(document.ActorChain); + } + + if (document.Devices.Count > 0) + { + properties["devices"] = JsonSerializer.Serialize(document.Devices); + } + + return properties; + } + + private static string? Get(IReadOnlyDictionary properties, string key, string? defaultValue) + { + if (properties.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + + return defaultValue; + } + + private static DateTimeOffset? ParseDate(IReadOnlyDictionary properties, string key) + { + if (properties.TryGetValue(key, out var value) && DateTimeOffset.TryParse(value, out var parsed)) + { + return parsed; + } + + return null; + } + + private static IReadOnlyDictionary? ExtractRevokedMetadata(IReadOnlyDictionary properties) + { + var metadata = properties + .Where(kvp => kvp.Key.StartsWith("revoked_meta_", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(kvp => kvp.Key["revoked_meta_".Length..], kvp => (string?)kvp.Value, StringComparer.OrdinalIgnoreCase); + + return metadata.Count == 0 ? null : metadata; + } + + private static AuthorityRefreshTokenDocument Map(OidcRefreshTokenEntity entity) => new() + { + Id = entity.Id, + TokenId = entity.TokenId, + SubjectId = entity.SubjectId, + ClientId = entity.ClientId, + Handle = entity.Handle, + CreatedAt = entity.CreatedAt, + ExpiresAt = entity.ExpiresAt, + ConsumedAt = entity.ConsumedAt, + Payload = entity.Payload + }; +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Migrations/002_mongo_store_equivalents.sql b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Migrations/002_mongo_store_equivalents.sql new file mode 100644 index 000000000..44aef7f8b --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Migrations/002_mongo_store_equivalents.sql @@ -0,0 +1,154 @@ +-- Authority Schema Migration 002: Mongo Store Equivalents +-- Adds PostgreSQL-backed tables that replace legacy MongoDB collections used by Authority. + +-- Bootstrap invites +CREATE TABLE IF NOT EXISTS authority.bootstrap_invites ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + provider TEXT, + target TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + issued_by TEXT, + reserved_until TIMESTAMPTZ, + reserved_by TEXT, + consumed BOOLEAN NOT NULL DEFAULT FALSE, + status TEXT NOT NULL DEFAULT 'pending', + metadata JSONB NOT NULL DEFAULT '{}' +); + +-- Service accounts +CREATE TABLE IF NOT EXISTS authority.service_accounts ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL UNIQUE, + tenant TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allowed_scopes TEXT[] NOT NULL DEFAULT '{}', + authorized_clients TEXT[] NOT NULL DEFAULT '{}', + attributes JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON authority.service_accounts(tenant); + +-- Clients +CREATE TABLE IF NOT EXISTS authority.clients ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL UNIQUE, + client_secret TEXT, + secret_hash TEXT, + display_name TEXT, + description TEXT, + plugin TEXT, + sender_constraint TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + redirect_uris TEXT[] NOT NULL DEFAULT '{}', + post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}', + allowed_scopes TEXT[] NOT NULL DEFAULT '{}', + allowed_grant_types TEXT[] NOT NULL DEFAULT '{}', + require_client_secret BOOLEAN NOT NULL DEFAULT TRUE, + require_pkce BOOLEAN NOT NULL DEFAULT FALSE, + allow_plain_text_pkce BOOLEAN NOT NULL DEFAULT FALSE, + client_type TEXT, + properties JSONB NOT NULL DEFAULT '{}', + certificate_bindings JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Revocations +CREATE TABLE IF NOT EXISTS authority.revocations ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + revocation_id TEXT NOT NULL, + subject_id TEXT, + client_id TEXT, + token_id TEXT, + reason TEXT NOT NULL, + reason_description TEXT, + revoked_at TIMESTAMPTZ NOT NULL, + effective_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}' +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_revocations_category_revocation_id + ON authority.revocations(category, revocation_id); + +-- Login attempts +CREATE TABLE IF NOT EXISTS authority.login_attempts ( + id TEXT PRIMARY KEY, + subject_id TEXT, + client_id TEXT, + event_type TEXT NOT NULL, + outcome TEXT NOT NULL, + reason TEXT, + ip_address TEXT, + user_agent TEXT, + occurred_at TIMESTAMPTZ NOT NULL, + properties JSONB NOT NULL DEFAULT '[]' +); + +CREATE INDEX IF NOT EXISTS idx_login_attempts_subject ON authority.login_attempts(subject_id, occurred_at DESC); + +-- OIDC tokens +CREATE TABLE IF NOT EXISTS authority.oidc_tokens ( + id TEXT PRIMARY KEY, + token_id TEXT NOT NULL UNIQUE, + subject_id TEXT, + client_id TEXT, + token_type TEXT NOT NULL, + reference_id TEXT, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + redeemed_at TIMESTAMPTZ, + payload TEXT, + properties JSONB NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_oidc_tokens_subject ON authority.oidc_tokens(subject_id); +CREATE INDEX IF NOT EXISTS idx_oidc_tokens_client ON authority.oidc_tokens(client_id); +CREATE INDEX IF NOT EXISTS idx_oidc_tokens_reference ON authority.oidc_tokens(reference_id); + +-- OIDC refresh tokens +CREATE TABLE IF NOT EXISTS authority.oidc_refresh_tokens ( + id TEXT PRIMARY KEY, + token_id TEXT NOT NULL UNIQUE, + subject_id TEXT, + client_id TEXT, + handle TEXT, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + consumed_at TIMESTAMPTZ, + payload TEXT +); + +CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_subject ON authority.oidc_refresh_tokens(subject_id); +CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_handle ON authority.oidc_refresh_tokens(handle); + +-- Airgap audit +CREATE TABLE IF NOT EXISTS authority.airgap_audit ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + operator_id TEXT, + component_id TEXT, + outcome TEXT NOT NULL, + reason TEXT, + occurred_at TIMESTAMPTZ NOT NULL, + properties JSONB NOT NULL DEFAULT '[]' +); + +CREATE INDEX IF NOT EXISTS idx_airgap_audit_occurred_at ON authority.airgap_audit(occurred_at DESC); + +-- Revocation export state (singleton row with optimistic concurrency) +CREATE TABLE IF NOT EXISTS authority.revocation_export_state ( + id INT PRIMARY KEY DEFAULT 1, + sequence BIGINT NOT NULL DEFAULT 0, + bundle_id TEXT, + issued_at TIMESTAMPTZ +); diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/AirgapAuditEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/AirgapAuditEntity.cs new file mode 100644 index 000000000..d703b675f --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/AirgapAuditEntity.cs @@ -0,0 +1,25 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents an air-gapped audit record. +/// +public sealed class AirgapAuditEntity +{ + public required string Id { get; init; } + public required string EventType { get; init; } + public string? OperatorId { get; init; } + public string? ComponentId { get; init; } + public required string Outcome { get; init; } + public string? Reason { get; init; } + public DateTimeOffset OccurredAt { get; init; } + public IReadOnlyList Properties { get; init; } = Array.Empty(); +} + +/// +/// Represents a property stored alongside an airgap audit record. +/// +public sealed class AirgapAuditPropertyEntity +{ + public required string Name { get; init; } + public required string Value { get; init; } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/BootstrapInviteEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/BootstrapInviteEntity.cs new file mode 100644 index 000000000..2dcfb876e --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/BootstrapInviteEntity.cs @@ -0,0 +1,22 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents a bootstrap invite seed. +/// +public sealed class BootstrapInviteEntity +{ + public required string Id { get; init; } + public required string Token { get; init; } + public required string Type { get; init; } + public string? Provider { get; init; } + public string? Target { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset IssuedAt { get; init; } + public string? IssuedBy { get; init; } + public DateTimeOffset? ReservedUntil { get; init; } + public string? ReservedBy { get; init; } + public bool Consumed { get; init; } + public string Status { get; init; } = "pending"; + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/ClientEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/ClientEntity.cs new file mode 100644 index 000000000..517cc33ad --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/ClientEntity.cs @@ -0,0 +1,46 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents an OAuth/OpenID Connect client configuration. +/// +public sealed class ClientEntity +{ + public required string Id { get; init; } + public required string ClientId { get; init; } + public string? ClientSecret { get; init; } + public string? SecretHash { get; init; } + public string? DisplayName { get; init; } + public string? Description { get; init; } + public string? Plugin { get; init; } + public string? SenderConstraint { get; init; } + public bool Enabled { get; init; } + public IReadOnlyList RedirectUris { get; init; } = Array.Empty(); + public IReadOnlyList PostLogoutRedirectUris { get; init; } = Array.Empty(); + public IReadOnlyList AllowedScopes { get; init; } = Array.Empty(); + public IReadOnlyList AllowedGrantTypes { get; init; } = Array.Empty(); + public bool RequireClientSecret { get; init; } + public bool RequirePkce { get; init; } + public bool AllowPlainTextPkce { get; init; } + public string? ClientType { get; init; } + public IReadOnlyDictionary Properties { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IReadOnlyList CertificateBindings { get; init; } = Array.Empty(); + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; init; } +} + +/// +/// Represents a certificate binding for mutual TLS clients. +/// +public sealed class ClientCertificateBindingEntity +{ + public string? Thumbprint { get; init; } + public string? SerialNumber { get; init; } + public string? Subject { get; init; } + public string? Issuer { get; init; } + public IReadOnlyList SubjectAlternativeNames { get; init; } = Array.Empty(); + public DateTimeOffset? NotBefore { get; init; } + public DateTimeOffset? NotAfter { get; init; } + public string? Label { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; init; } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/LoginAttemptEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/LoginAttemptEntity.cs new file mode 100644 index 000000000..cee0cbd62 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/LoginAttemptEntity.cs @@ -0,0 +1,29 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents a login attempt. +/// +public sealed class LoginAttemptEntity +{ + public required string Id { get; init; } + public string? SubjectId { get; init; } + public string? ClientId { get; init; } + public required string EventType { get; init; } + public required string Outcome { get; init; } + public string? Reason { get; init; } + public string? IpAddress { get; init; } + public string? UserAgent { get; init; } + public DateTimeOffset OccurredAt { get; init; } + public IReadOnlyList Properties { get; init; } = Array.Empty(); +} + +/// +/// Represents a property attached to a login attempt. +/// +public sealed class LoginAttemptPropertyEntity +{ + public required string Name { get; init; } + public required string Value { get; init; } + public bool Sensitive { get; init; } + public string Classification { get; init; } = "none"; +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/OidcTokenEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/OidcTokenEntity.cs new file mode 100644 index 000000000..117b5234d --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/OidcTokenEntity.cs @@ -0,0 +1,35 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents an OpenIddict token persisted in PostgreSQL. +/// +public sealed class OidcTokenEntity +{ + public required string Id { get; init; } + public required string TokenId { get; init; } + public string? SubjectId { get; init; } + public string? ClientId { get; init; } + public required string TokenType { get; init; } + public string? ReferenceId { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? ExpiresAt { get; init; } + public DateTimeOffset? RedeemedAt { get; init; } + public string? Payload { get; init; } + public IReadOnlyDictionary Properties { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} + +/// +/// Represents a refresh token persisted in PostgreSQL. +/// +public sealed class OidcRefreshTokenEntity +{ + public required string Id { get; init; } + public required string TokenId { get; init; } + public string? SubjectId { get; init; } + public string? ClientId { get; init; } + public string? Handle { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? ExpiresAt { get; init; } + public DateTimeOffset? ConsumedAt { get; init; } + public string? Payload { get; init; } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/RevocationEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/RevocationEntity.cs new file mode 100644 index 000000000..4923469e2 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/RevocationEntity.cs @@ -0,0 +1,20 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents a revocation record. +/// +public sealed class RevocationEntity +{ + public required string Id { get; init; } + public required string Category { get; init; } + public required string RevocationId { get; init; } + public string SubjectId { get; init; } = string.Empty; + public string? ClientId { get; init; } + public string? TokenId { get; init; } + public required string Reason { get; init; } + public string? ReasonDescription { get; init; } + public DateTimeOffset RevokedAt { get; init; } + public DateTimeOffset EffectiveAt { get; init; } + public DateTimeOffset? ExpiresAt { get; init; } + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/RevocationExportStateEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/RevocationExportStateEntity.cs new file mode 100644 index 000000000..e3f36d3ad --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/RevocationExportStateEntity.cs @@ -0,0 +1,12 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents the last exported revocation bundle metadata. +/// +public sealed class RevocationExportStateEntity +{ + public int Id { get; set; } = 1; + public long Sequence { get; set; } + public string? BundleId { get; set; } + public DateTimeOffset? IssuedAt { get; set; } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/ServiceAccountEntity.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/ServiceAccountEntity.cs new file mode 100644 index 000000000..ebef0475b --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Models/ServiceAccountEntity.cs @@ -0,0 +1,19 @@ +namespace StellaOps.Authority.Storage.Postgres.Models; + +/// +/// Represents a service account configuration. +/// +public sealed class ServiceAccountEntity +{ + public required string Id { get; init; } + public required string AccountId { get; init; } + public required string Tenant { get; init; } + public required string DisplayName { get; init; } + public string? Description { get; init; } + public bool Enabled { get; init; } + public IReadOnlyList AllowedScopes { get; init; } = Array.Empty(); + public IReadOnlyList AuthorizedClients { get; init; } = Array.Empty(); + public IReadOnlyDictionary> Attributes { get; init; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; init; } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AirgapAuditRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AirgapAuditRepository.cs new file mode 100644 index 000000000..7897db49d --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/AirgapAuditRepository.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for airgap audit records. +/// +public sealed class AirgapAuditRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public AirgapAuditRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task InsertAsync(AirgapAuditEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.airgap_audit + (id, event_type, operator_id, component_id, outcome, reason, occurred_at, properties) + VALUES (@id, @event_type, @operator_id, @component_id, @outcome, @reason, @occurred_at, @properties) + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "event_type", entity.EventType); + AddParameter(cmd, "operator_id", entity.OperatorId); + AddParameter(cmd, "component_id", entity.ComponentId); + AddParameter(cmd, "outcome", entity.Outcome); + AddParameter(cmd, "reason", entity.Reason); + AddParameter(cmd, "occurred_at", entity.OccurredAt); + AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions)); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(int limit, int offset, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, event_type, operator_id, component_id, outcome, reason, occurred_at, properties + FROM authority.airgap_audit + ORDER BY occurred_at DESC + LIMIT @limit OFFSET @offset + """; + + return await QueryAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "limit", limit); + AddParameter(cmd, "offset", offset); + }, + mapRow: MapAudit, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static AirgapAuditEntity MapAudit(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + EventType = reader.GetString(1), + OperatorId = GetNullableString(reader, 2), + ComponentId = GetNullableString(reader, 3), + Outcome = reader.GetString(4), + Reason = GetNullableString(reader, 5), + OccurredAt = reader.GetFieldValue(6), + Properties = DeserializeProperties(reader, 7) + }; + + private static IReadOnlyList DeserializeProperties(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return Array.Empty(); + } + + var json = reader.GetString(ordinal); + List? parsed = JsonSerializer.Deserialize>(json, SerializerOptions); + return parsed ?? new List(); + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/BootstrapInviteRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/BootstrapInviteRepository.cs new file mode 100644 index 000000000..2004eac23 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/BootstrapInviteRepository.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for bootstrap invites. +/// +public sealed class BootstrapInviteRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public BootstrapInviteRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task FindByTokenAsync(string token, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata + FROM authority.bootstrap_invites + WHERE token = @token + """; + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "token", token), + mapRow: MapInvite, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task InsertAsync(BootstrapInviteEntity invite, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.bootstrap_invites + (id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata) + VALUES (@id, @token, @type, @provider, @target, @expires_at, @created_at, @issued_at, @issued_by, @reserved_until, @reserved_by, @consumed, @status, @metadata) + """; + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", invite.Id); + AddParameter(cmd, "token", invite.Token); + AddParameter(cmd, "type", invite.Type); + AddParameter(cmd, "provider", invite.Provider); + AddParameter(cmd, "target", invite.Target); + AddParameter(cmd, "expires_at", invite.ExpiresAt); + AddParameter(cmd, "created_at", invite.CreatedAt); + AddParameter(cmd, "issued_at", invite.IssuedAt); + AddParameter(cmd, "issued_by", invite.IssuedBy); + AddParameter(cmd, "reserved_until", invite.ReservedUntil); + AddParameter(cmd, "reserved_by", invite.ReservedBy); + AddParameter(cmd, "consumed", invite.Consumed); + AddParameter(cmd, "status", invite.Status); + AddJsonbParameter(cmd, "metadata", JsonSerializer.Serialize(invite.Metadata, SerializerOptions)); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task ConsumeAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken = default) + { + const string sql = """ + UPDATE authority.bootstrap_invites + SET consumed = TRUE, + reserved_by = @consumed_by, + reserved_until = @consumed_at, + status = 'consumed' + WHERE token = @token AND consumed = FALSE + """; + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "token", token); + AddParameter(cmd, "consumed_by", consumedBy); + AddParameter(cmd, "consumed_at", consumedAt); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + public async Task ReleaseAsync(string token, CancellationToken cancellationToken = default) + { + const string sql = """ + UPDATE authority.bootstrap_invites + SET status = 'pending', + reserved_by = NULL, + reserved_until = NULL + WHERE token = @token AND status = 'reserved' + """; + + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "token", token), + cancellationToken: cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + public async Task TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken = default) + { + const string sql = """ + UPDATE authority.bootstrap_invites + SET status = 'reserved', + reserved_by = @reserved_by, + reserved_until = @reserved_until + WHERE token = @token + AND type = @expected_type + AND consumed = FALSE + AND expires_at > @now + AND (status = 'pending' OR status IS NULL) + """; + + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "reserved_by", reservedBy); + AddParameter(cmd, "reserved_until", now.AddMinutes(15)); + AddParameter(cmd, "token", token); + AddParameter(cmd, "expected_type", expectedType); + AddParameter(cmd, "now", now); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return rows > 0; + } + + public async Task> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default) + { + const string selectSql = """ + SELECT id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata + FROM authority.bootstrap_invites + WHERE expires_at <= @as_of + """; + const string deleteSql = """ + DELETE FROM authority.bootstrap_invites + WHERE expires_at <= @as_of + """; + + var expired = await QueryAsync( + tenantId: string.Empty, + sql: selectSql, + configureCommand: cmd => AddParameter(cmd, "as_of", asOf), + mapRow: MapInvite, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await ExecuteAsync( + tenantId: string.Empty, + sql: deleteSql, + configureCommand: cmd => AddParameter(cmd, "as_of", asOf), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return expired; + } + + private static BootstrapInviteEntity MapInvite(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + Token = reader.GetString(1), + Type = reader.GetString(2), + Provider = GetNullableString(reader, 3), + Target = GetNullableString(reader, 4), + ExpiresAt = reader.GetFieldValue(5), + CreatedAt = reader.GetFieldValue(6), + IssuedAt = reader.GetFieldValue(7), + IssuedBy = GetNullableString(reader, 8), + ReservedUntil = reader.IsDBNull(9) ? null : reader.GetFieldValue(9), + ReservedBy = GetNullableString(reader, 10), + Consumed = reader.GetBoolean(11), + Status = GetNullableString(reader, 12) ?? "pending", + Metadata = DeserializeMetadata(reader, 13) + }; + + private static IReadOnlyDictionary DeserializeMetadata(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var json = reader.GetString(ordinal); + return JsonSerializer.Deserialize>(json, SerializerOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ClientRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ClientRepository.cs new file mode 100644 index 000000000..05bf7b04c --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ClientRepository.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for OAuth/OpenID clients. +/// +public sealed class ClientRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public ClientRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, client_id, client_secret, secret_hash, display_name, description, plugin, sender_constraint, + enabled, redirect_uris, post_logout_redirect_uris, allowed_scopes, allowed_grant_types, + require_client_secret, require_pkce, allow_plain_text_pkce, client_type, properties, certificate_bindings, + created_at, updated_at + FROM authority.clients + WHERE client_id = @client_id + """; + + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "client_id", clientId), + mapRow: MapClient, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.clients + (id, client_id, client_secret, secret_hash, display_name, description, plugin, sender_constraint, + enabled, redirect_uris, post_logout_redirect_uris, allowed_scopes, allowed_grant_types, + require_client_secret, require_pkce, allow_plain_text_pkce, client_type, properties, certificate_bindings, + created_at, updated_at) + VALUES + (@id, @client_id, @client_secret, @secret_hash, @display_name, @description, @plugin, @sender_constraint, + @enabled, @redirect_uris, @post_logout_redirect_uris, @allowed_scopes, @allowed_grant_types, + @require_client_secret, @require_pkce, @allow_plain_text_pkce, @client_type, @properties, @certificate_bindings, + @created_at, @updated_at) + ON CONFLICT (client_id) DO UPDATE + SET client_secret = EXCLUDED.client_secret, + secret_hash = EXCLUDED.secret_hash, + display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + plugin = EXCLUDED.plugin, + sender_constraint = EXCLUDED.sender_constraint, + enabled = EXCLUDED.enabled, + redirect_uris = EXCLUDED.redirect_uris, + post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris, + allowed_scopes = EXCLUDED.allowed_scopes, + allowed_grant_types = EXCLUDED.allowed_grant_types, + require_client_secret = EXCLUDED.require_client_secret, + require_pkce = EXCLUDED.require_pkce, + allow_plain_text_pkce = EXCLUDED.allow_plain_text_pkce, + client_type = EXCLUDED.client_type, + properties = EXCLUDED.properties, + certificate_bindings = EXCLUDED.certificate_bindings, + updated_at = EXCLUDED.updated_at + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "client_id", entity.ClientId); + AddParameter(cmd, "client_secret", entity.ClientSecret); + AddParameter(cmd, "secret_hash", entity.SecretHash); + AddParameter(cmd, "display_name", entity.DisplayName); + AddParameter(cmd, "description", entity.Description); + AddParameter(cmd, "plugin", entity.Plugin); + AddParameter(cmd, "sender_constraint", entity.SenderConstraint); + AddParameter(cmd, "enabled", entity.Enabled); + AddParameter(cmd, "redirect_uris", entity.RedirectUris.ToArray()); + AddParameter(cmd, "post_logout_redirect_uris", entity.PostLogoutRedirectUris.ToArray()); + AddParameter(cmd, "allowed_scopes", entity.AllowedScopes.ToArray()); + AddParameter(cmd, "allowed_grant_types", entity.AllowedGrantTypes.ToArray()); + AddParameter(cmd, "require_client_secret", entity.RequireClientSecret); + AddParameter(cmd, "require_pkce", entity.RequirePkce); + AddParameter(cmd, "allow_plain_text_pkce", entity.AllowPlainTextPkce); + AddParameter(cmd, "client_type", entity.ClientType); + AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions)); + AddJsonbParameter(cmd, "certificate_bindings", JsonSerializer.Serialize(entity.CertificateBindings, SerializerOptions)); + AddParameter(cmd, "created_at", entity.CreatedAt); + AddParameter(cmd, "updated_at", entity.UpdatedAt); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default) + { + const string sql = "DELETE FROM authority.clients WHERE client_id = @client_id"; + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "client_id", clientId), + cancellationToken: cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + private static ClientEntity MapClient(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + ClientId = reader.GetString(1), + ClientSecret = GetNullableString(reader, 2), + SecretHash = GetNullableString(reader, 3), + DisplayName = GetNullableString(reader, 4), + Description = GetNullableString(reader, 5), + Plugin = GetNullableString(reader, 6), + SenderConstraint = GetNullableString(reader, 7), + Enabled = reader.GetBoolean(8), + RedirectUris = reader.GetFieldValue(9), + PostLogoutRedirectUris = reader.GetFieldValue(10), + AllowedScopes = reader.GetFieldValue(11), + AllowedGrantTypes = reader.GetFieldValue(12), + RequireClientSecret = reader.GetBoolean(13), + RequirePkce = reader.GetBoolean(14), + AllowPlainTextPkce = reader.GetBoolean(15), + ClientType = GetNullableString(reader, 16), + Properties = DeserializeDictionary(reader, 17), + CertificateBindings = Deserialize>(reader, 18) ?? new List(), + CreatedAt = reader.GetFieldValue(19), + UpdatedAt = reader.GetFieldValue(20) + }; + + private static IReadOnlyDictionary DeserializeDictionary(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var json = reader.GetString(ordinal); + return JsonSerializer.Deserialize>(json, SerializerOptions) ?? + new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static T? Deserialize(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return default; + } + + var json = reader.GetString(ordinal); + return JsonSerializer.Deserialize(json, SerializerOptions); + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/LoginAttemptRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/LoginAttemptRepository.cs new file mode 100644 index 000000000..69d34d4ef --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/LoginAttemptRepository.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for login attempts. +/// +public sealed class LoginAttemptRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public LoginAttemptRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task InsertAsync(LoginAttemptEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.login_attempts + (id, subject_id, client_id, event_type, outcome, reason, ip_address, user_agent, occurred_at, properties) + VALUES (@id, @subject_id, @client_id, @event_type, @outcome, @reason, @ip_address, @user_agent, @occurred_at, @properties) + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "subject_id", entity.SubjectId); + AddParameter(cmd, "client_id", entity.ClientId); + AddParameter(cmd, "event_type", entity.EventType); + AddParameter(cmd, "outcome", entity.Outcome); + AddParameter(cmd, "reason", entity.Reason); + AddParameter(cmd, "ip_address", entity.IpAddress); + AddParameter(cmd, "user_agent", entity.UserAgent); + AddParameter(cmd, "occurred_at", entity.OccurredAt); + AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions)); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, subject_id, client_id, event_type, outcome, reason, ip_address, user_agent, occurred_at, properties + FROM authority.login_attempts + WHERE subject_id = @subject_id + ORDER BY occurred_at DESC + LIMIT @limit + """; + + return await QueryAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "subject_id", subjectId); + AddParameter(cmd, "limit", limit); + }, + mapRow: MapLoginAttempt, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static LoginAttemptEntity MapLoginAttempt(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + SubjectId = GetNullableString(reader, 1), + ClientId = GetNullableString(reader, 2), + EventType = reader.GetString(3), + Outcome = reader.GetString(4), + Reason = GetNullableString(reader, 5), + IpAddress = GetNullableString(reader, 6), + UserAgent = GetNullableString(reader, 7), + OccurredAt = reader.GetFieldValue(8), + Properties = DeserializeProperties(reader, 9) + }; + + private static IReadOnlyList DeserializeProperties(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return Array.Empty(); + } + + var json = reader.GetString(ordinal); + List? parsed = JsonSerializer.Deserialize>(json, SerializerOptions); + return parsed ?? new List(); + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/OidcTokenRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/OidcTokenRepository.cs new file mode 100644 index 000000000..d7cd4dcf2 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/OidcTokenRepository.cs @@ -0,0 +1,286 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for OpenIddict tokens and refresh tokens. +/// +public sealed class OidcTokenRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public OidcTokenRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties + FROM authority.oidc_tokens + WHERE token_id = @token_id + """; + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "token_id", tokenId), + mapRow: MapToken, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties + FROM authority.oidc_tokens + WHERE reference_id = @reference_id + """; + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "reference_id", referenceId), + mapRow: MapToken, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties + FROM authority.oidc_tokens + WHERE subject_id = @subject_id + ORDER BY created_at DESC + LIMIT @limit + """; + return await QueryAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "subject_id", subjectId); + AddParameter(cmd, "limit", limit); + }, + mapRow: MapToken, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(int limit, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties + FROM authority.oidc_tokens + ORDER BY created_at DESC + LIMIT @limit + """; + + return await QueryAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "limit", limit), + mapRow: MapToken, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UpsertAsync(OidcTokenEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.oidc_tokens + (id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties) + VALUES (@id, @token_id, @subject_id, @client_id, @token_type, @reference_id, @created_at, @expires_at, @redeemed_at, @payload, @properties) + ON CONFLICT (token_id) DO UPDATE + SET subject_id = EXCLUDED.subject_id, + client_id = EXCLUDED.client_id, + token_type = EXCLUDED.token_type, + reference_id = EXCLUDED.reference_id, + created_at = EXCLUDED.created_at, + expires_at = EXCLUDED.expires_at, + redeemed_at = EXCLUDED.redeemed_at, + payload = EXCLUDED.payload, + properties = EXCLUDED.properties + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "token_id", entity.TokenId); + AddParameter(cmd, "subject_id", entity.SubjectId); + AddParameter(cmd, "client_id", entity.ClientId); + AddParameter(cmd, "token_type", entity.TokenType); + AddParameter(cmd, "reference_id", entity.ReferenceId); + AddParameter(cmd, "created_at", entity.CreatedAt); + AddParameter(cmd, "expires_at", entity.ExpiresAt); + AddParameter(cmd, "redeemed_at", entity.RedeemedAt); + AddParameter(cmd, "payload", entity.Payload); + AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions)); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task RevokeAsync(string tokenId, CancellationToken cancellationToken = default) + { + const string sql = "DELETE FROM authority.oidc_tokens WHERE token_id = @token_id"; + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "token_id", tokenId), + cancellationToken: cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + public async Task RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken = default) + { + const string sql = "DELETE FROM authority.oidc_tokens WHERE subject_id = @subject_id"; + return await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "subject_id", subjectId), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task RevokeByClientAsync(string clientId, CancellationToken cancellationToken = default) + { + const string sql = "DELETE FROM authority.oidc_tokens WHERE client_id = @client_id"; + return await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "client_id", clientId), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task FindRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload + FROM authority.oidc_refresh_tokens + WHERE token_id = @token_id + """; + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "token_id", tokenId), + mapRow: MapRefreshToken, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task FindRefreshTokenByHandleAsync(string handle, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload + FROM authority.oidc_refresh_tokens + WHERE handle = @handle + """; + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "handle", handle), + mapRow: MapRefreshToken, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UpsertRefreshTokenAsync(OidcRefreshTokenEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.oidc_refresh_tokens + (id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload) + VALUES (@id, @token_id, @subject_id, @client_id, @handle, @created_at, @expires_at, @consumed_at, @payload) + ON CONFLICT (token_id) DO UPDATE + SET subject_id = EXCLUDED.subject_id, + client_id = EXCLUDED.client_id, + handle = EXCLUDED.handle, + created_at = EXCLUDED.created_at, + expires_at = EXCLUDED.expires_at, + consumed_at = EXCLUDED.consumed_at, + payload = EXCLUDED.payload + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "token_id", entity.TokenId); + AddParameter(cmd, "subject_id", entity.SubjectId); + AddParameter(cmd, "client_id", entity.ClientId); + AddParameter(cmd, "handle", entity.Handle); + AddParameter(cmd, "created_at", entity.CreatedAt); + AddParameter(cmd, "expires_at", entity.ExpiresAt); + AddParameter(cmd, "consumed_at", entity.ConsumedAt); + AddParameter(cmd, "payload", entity.Payload); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task ConsumeRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default) + { + const string sql = """ + UPDATE authority.oidc_refresh_tokens + SET consumed_at = NOW() + WHERE token_id = @token_id AND consumed_at IS NULL + """; + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "token_id", tokenId), + cancellationToken: cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + public async Task RevokeRefreshTokensBySubjectAsync(string subjectId, CancellationToken cancellationToken = default) + { + const string sql = "DELETE FROM authority.oidc_refresh_tokens WHERE subject_id = @subject_id"; + return await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "subject_id", subjectId), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static OidcTokenEntity MapToken(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + TokenId = reader.GetString(1), + SubjectId = GetNullableString(reader, 2), + ClientId = GetNullableString(reader, 3), + TokenType = reader.GetString(4), + ReferenceId = GetNullableString(reader, 5), + CreatedAt = reader.GetFieldValue(6), + ExpiresAt = reader.IsDBNull(7) ? null : reader.GetFieldValue(7), + RedeemedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue(8), + Payload = GetNullableString(reader, 9), + Properties = DeserializeProperties(reader, 10) + }; + + private static OidcRefreshTokenEntity MapRefreshToken(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + TokenId = reader.GetString(1), + SubjectId = GetNullableString(reader, 2), + ClientId = GetNullableString(reader, 3), + Handle = GetNullableString(reader, 4), + CreatedAt = reader.GetFieldValue(5), + ExpiresAt = reader.IsDBNull(6) ? null : reader.GetFieldValue(6), + ConsumedAt = reader.IsDBNull(7) ? null : reader.GetFieldValue(7), + Payload = GetNullableString(reader, 8) + }; + + private static IReadOnlyDictionary DeserializeProperties(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var json = reader.GetString(ordinal); + return JsonSerializer.Deserialize>(json, SerializerOptions) ?? + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/RevocationExportStateRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/RevocationExportStateRepository.cs new file mode 100644 index 000000000..59bb694e2 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/RevocationExportStateRepository.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// Repository that persists revocation export sequence state. +/// +public sealed class RevocationExportStateRepository : RepositoryBase +{ + public RevocationExportStateRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, sequence, bundle_id, issued_at + FROM authority.revocation_export_state + WHERE id = 1 + """; + + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: static _ => { }, + mapRow: MapState, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UpsertAsync(long expectedSequence, RevocationExportStateEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.revocation_export_state (id, sequence, bundle_id, issued_at) + VALUES (1, @sequence, @bundle_id, @issued_at) + ON CONFLICT (id) DO UPDATE + SET sequence = EXCLUDED.sequence, + bundle_id = EXCLUDED.bundle_id, + issued_at = EXCLUDED.issued_at + WHERE authority.revocation_export_state.sequence = @expected_sequence + """; + + var affected = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "sequence", entity.Sequence); + AddParameter(cmd, "bundle_id", entity.BundleId); + AddParameter(cmd, "issued_at", entity.IssuedAt); + AddParameter(cmd, "expected_sequence", expectedSequence); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (affected == 0) + { + throw new InvalidOperationException($"Revocation export state update rejected. Expected sequence {expectedSequence}."); + } + } + + private static RevocationExportStateEntity MapState(NpgsqlDataReader reader) => new() + { + Id = reader.GetInt32(0), + Sequence = reader.GetInt64(1), + BundleId = reader.IsDBNull(2) ? null : reader.GetString(2), + IssuedAt = reader.IsDBNull(3) ? null : reader.GetFieldValue(3) + }; +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/RevocationRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/RevocationRepository.cs new file mode 100644 index 000000000..28655d214 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/RevocationRepository.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for revocations. +/// +public sealed class RevocationRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public RevocationRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task UpsertAsync(RevocationEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.revocations + (id, category, revocation_id, subject_id, client_id, token_id, reason, reason_description, revoked_at, effective_at, expires_at, metadata) + VALUES (@id, @category, @revocation_id, @subject_id, @client_id, @token_id, @reason, @reason_description, @revoked_at, @effective_at, @expires_at, @metadata) + ON CONFLICT (category, revocation_id) DO UPDATE + SET subject_id = EXCLUDED.subject_id, + client_id = EXCLUDED.client_id, + token_id = EXCLUDED.token_id, + reason = EXCLUDED.reason, + reason_description = EXCLUDED.reason_description, + revoked_at = EXCLUDED.revoked_at, + effective_at = EXCLUDED.effective_at, + expires_at = EXCLUDED.expires_at, + metadata = EXCLUDED.metadata + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "category", entity.Category); + AddParameter(cmd, "revocation_id", entity.RevocationId); + AddParameter(cmd, "subject_id", entity.SubjectId); + AddParameter(cmd, "client_id", entity.ClientId); + AddParameter(cmd, "token_id", entity.TokenId); + AddParameter(cmd, "reason", entity.Reason); + AddParameter(cmd, "reason_description", entity.ReasonDescription); + AddParameter(cmd, "revoked_at", entity.RevokedAt); + AddParameter(cmd, "effective_at", entity.EffectiveAt); + AddParameter(cmd, "expires_at", entity.ExpiresAt); + AddJsonbParameter(cmd, "metadata", JsonSerializer.Serialize(entity.Metadata, SerializerOptions)); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, category, revocation_id, subject_id, client_id, token_id, reason, reason_description, revoked_at, effective_at, expires_at, metadata + FROM authority.revocations + WHERE effective_at <= @as_of + AND (expires_at IS NULL OR expires_at > @as_of) + """; + + return await QueryAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "as_of", asOf), + mapRow: MapRevocation, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task RemoveAsync(string category, string revocationId, CancellationToken cancellationToken = default) + { + const string sql = """ + DELETE FROM authority.revocations + WHERE category = @category AND revocation_id = @revocation_id + """; + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "category", category); + AddParameter(cmd, "revocation_id", revocationId); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static RevocationEntity MapRevocation(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + Category = reader.GetString(1), + RevocationId = reader.GetString(2), + SubjectId = reader.IsDBNull(3) ? string.Empty : reader.GetString(3), + ClientId = GetNullableString(reader, 4), + TokenId = GetNullableString(reader, 5), + Reason = reader.GetString(6), + ReasonDescription = GetNullableString(reader, 7), + RevokedAt = reader.GetFieldValue(8), + EffectiveAt = reader.GetFieldValue(9), + ExpiresAt = reader.IsDBNull(10) ? null : reader.GetFieldValue(10), + Metadata = DeserializeMetadata(reader, 11) + }; + + private static IReadOnlyDictionary DeserializeMetadata(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var json = reader.GetString(ordinal); + Dictionary? parsed = JsonSerializer.Deserialize>(json, SerializerOptions); + return parsed ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ServiceAccountRepository.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ServiceAccountRepository.cs new file mode 100644 index 000000000..6c5224290 --- /dev/null +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/Repositories/ServiceAccountRepository.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Authority.Storage.Postgres.Models; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.Authority.Storage.Postgres.Repositories; + +/// +/// PostgreSQL repository for service accounts. +/// +public sealed class ServiceAccountRepository : RepositoryBase +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General); + + public ServiceAccountRepository(AuthorityDataSource dataSource, ILogger logger) + : base(dataSource, logger) + { + } + + public async Task FindByAccountIdAsync(string accountId, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT id, account_id, tenant, display_name, description, enabled, + allowed_scopes, authorized_clients, attributes, created_at, updated_at + FROM authority.service_accounts + WHERE account_id = @account_id + """; + + return await QuerySingleOrDefaultAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "account_id", accountId), + mapRow: MapServiceAccount, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(string? tenant, CancellationToken cancellationToken = default) + { + var sql = """ + SELECT id, account_id, tenant, display_name, description, enabled, + allowed_scopes, authorized_clients, attributes, created_at, updated_at + FROM authority.service_accounts + """; + if (!string.IsNullOrWhiteSpace(tenant)) + { + sql += " WHERE tenant = @tenant"; + } + + return await QueryAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + if (!string.IsNullOrWhiteSpace(tenant)) + { + AddParameter(cmd, "tenant", tenant); + } + }, + mapRow: MapServiceAccount, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task UpsertAsync(ServiceAccountEntity entity, CancellationToken cancellationToken = default) + { + const string sql = """ + INSERT INTO authority.service_accounts + (id, account_id, tenant, display_name, description, enabled, allowed_scopes, authorized_clients, attributes, created_at, updated_at) + VALUES (@id, @account_id, @tenant, @display_name, @description, @enabled, @allowed_scopes, @authorized_clients, @attributes, @created_at, @updated_at) + ON CONFLICT (account_id) DO UPDATE + SET tenant = EXCLUDED.tenant, + display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + enabled = EXCLUDED.enabled, + allowed_scopes = EXCLUDED.allowed_scopes, + authorized_clients = EXCLUDED.authorized_clients, + attributes = EXCLUDED.attributes, + updated_at = EXCLUDED.updated_at + """; + + await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => + { + AddParameter(cmd, "id", entity.Id); + AddParameter(cmd, "account_id", entity.AccountId); + AddParameter(cmd, "tenant", entity.Tenant); + AddParameter(cmd, "display_name", entity.DisplayName); + AddParameter(cmd, "description", entity.Description); + AddParameter(cmd, "enabled", entity.Enabled); + AddParameter(cmd, "allowed_scopes", entity.AllowedScopes.ToArray()); + AddParameter(cmd, "authorized_clients", entity.AuthorizedClients.ToArray()); + AddJsonbParameter(cmd, "attributes", JsonSerializer.Serialize(entity.Attributes, SerializerOptions)); + AddParameter(cmd, "created_at", entity.CreatedAt); + AddParameter(cmd, "updated_at", entity.UpdatedAt); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteAsync(string accountId, CancellationToken cancellationToken = default) + { + const string sql = """ + DELETE FROM authority.service_accounts WHERE account_id = @account_id + """; + var rows = await ExecuteAsync( + tenantId: string.Empty, + sql: sql, + configureCommand: cmd => AddParameter(cmd, "account_id", accountId), + cancellationToken: cancellationToken).ConfigureAwait(false); + return rows > 0; + } + + private static ServiceAccountEntity MapServiceAccount(NpgsqlDataReader reader) => new() + { + Id = reader.GetString(0), + AccountId = reader.GetString(1), + Tenant = reader.GetString(2), + DisplayName = reader.GetString(3), + Description = GetNullableString(reader, 4), + Enabled = reader.GetBoolean(5), + AllowedScopes = reader.GetFieldValue(6), + AuthorizedClients = reader.GetFieldValue(7), + Attributes = ReadDictionary(reader, 8), + CreatedAt = reader.GetFieldValue(9), + UpdatedAt = reader.GetFieldValue(10) + }; + + private static IReadOnlyDictionary> ReadDictionary(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + var json = reader.GetString(ordinal); + var dictionary = System.Text.Json.JsonSerializer.Deserialize>>(json) ?? + new Dictionary>(StringComparer.OrdinalIgnoreCase); + return dictionary; + } +} diff --git a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/ServiceCollectionExtensions.cs b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/ServiceCollectionExtensions.cs index c12f18290..c6b03209a 100644 --- a/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/ServiceCollectionExtensions.cs +++ b/src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres/ServiceCollectionExtensions.cs @@ -66,5 +66,15 @@ public static class ServiceCollectionExtensions services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); + + // Mongo-store equivalents (PostgreSQL-backed) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a279b2534..e8271023e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -44,8 +44,8 @@ - - + + diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj index f41fd9e22..e83576028 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj @@ -15,7 +15,6 @@ - diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.Tests/StellaOps.Signer.Tests.csproj b/src/Signer/StellaOps.Signer/StellaOps.Signer.Tests/StellaOps.Signer.Tests.csproj index d2d197ada..60582beca 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.Tests/StellaOps.Signer.Tests.csproj +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.Tests/StellaOps.Signer.Tests.csproj @@ -12,7 +12,6 @@ - diff --git a/src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Facade.cs b/src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Facade.cs index 4291dfb7f..56e3b475f 100644 --- a/src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Facade.cs +++ b/src/__Libraries/StellaOps.Cryptography.Kms/Pkcs11Facade.cs @@ -1,16 +1,10 @@ -using Net.Pkcs11Interop.Common; -using Net.Pkcs11Interop.HighLevelAPI; -using Net.Pkcs11Interop.HighLevelAPI.MechanismParams; -using Pkcs11 = Net.Pkcs11Interop.HighLevelAPI.Pkcs11; -using Slot = Net.Pkcs11Interop.HighLevelAPI.Slot; -using ISession = Net.Pkcs11Interop.HighLevelAPI.Session; -using ObjectHandle = Net.Pkcs11Interop.HighLevelAPI.ObjectHandle; -using ObjectAttribute = Net.Pkcs11Interop.HighLevelAPI.ObjectAttribute; -using Mechanism = Net.Pkcs11Interop.HighLevelAPI.Mechanism; using System.Collections.Concurrent; using System.Formats.Asn1; using System.Security.Cryptography; using Microsoft.IdentityModel.Tokens; +using Net.Pkcs11Interop.Common; +using Net.Pkcs11Interop.HighLevelAPI; +using Net.Pkcs11Interop.HighLevelAPI.Factories; namespace StellaOps.Cryptography.Kms; @@ -37,9 +31,10 @@ public sealed record Pkcs11PublicKeyMaterial( internal sealed class Pkcs11InteropFacade : IPkcs11Facade { private readonly Pkcs11Options _options; - private readonly Pkcs11 _library; - private readonly Slot _slot; - private readonly ConcurrentDictionary _attributeCache = new(StringComparer.Ordinal); + private readonly Pkcs11InteropFactories _factories; + private readonly IPkcs11Library _library; + private readonly ISlot _slot; + private readonly ConcurrentDictionary _attributeCache = new(StringComparer.Ordinal); public Pkcs11InteropFacade(Pkcs11Options options) { @@ -49,7 +44,8 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade throw new ArgumentException("PKCS#11 library path must be provided.", nameof(options)); } - _library = new Pkcs11(_options.LibraryPath, AppType.MultiThreaded); + _factories = new Pkcs11InteropFactories(); + _library = _factories.Pkcs11LibraryFactory.LoadPkcs11Library(_factories, _options.LibraryPath, AppType.MultiThreaded); _slot = ResolveSlot(_library, _options) ?? throw new InvalidOperationException("Could not resolve PKCS#11 slot."); } @@ -116,7 +112,7 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade var privateHandle = FindKey(session, CKO.CKO_PRIVATE_KEY, _options.PrivateKeyLabel) ?? throw new InvalidOperationException("PKCS#11 private key not found."); - var mechanism = new Mechanism(_options.MechanismId); + var mechanism = _factories.MechanismFactory.Create(_options.MechanismId); return session.Sign(mechanism, privateHandle, digest.ToArray()); } @@ -149,23 +145,23 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade } } - private ObjectHandle? FindKey(ISession session, CKO objectClass, string? label) + private IObjectHandle? FindKey(ISession session, CKO objectClass, string? label) { - var template = new List + var template = new List { - new(CKA.CKA_CLASS, (uint)objectClass) + _factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, (uint)objectClass) }; if (!string.IsNullOrWhiteSpace(label)) { - template.Add(new ObjectAttribute(CKA.CKA_LABEL, label)); + template.Add(_factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label)); } var handles = session.FindAllObjects(template); return handles.FirstOrDefault(); } - private ObjectAttribute? GetAttribute(ISession session, ObjectHandle handle, CKA type) + private IObjectAttribute? GetAttribute(ISession session, IObjectHandle handle, CKA type) { var cacheKey = $"{handle.ObjectId}:{(uint)type}"; if (_attributeCache.TryGetValue(cacheKey, out var cached)) @@ -174,8 +170,7 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade } var attributes = session.GetAttributeValue(handle, new List { type }) - ?.Select(attr => new ObjectAttribute(attr.Type, attr.GetValueAsByteArray())) - .ToArray() ?? Array.Empty(); + ?.ToArray() ?? Array.Empty(); if (attributes.Length > 0) { @@ -186,7 +181,7 @@ internal sealed class Pkcs11InteropFacade : IPkcs11Facade return null; } - private static Slot? ResolveSlot(Pkcs11 pkcs11, Pkcs11Options options) + private static ISlot? ResolveSlot(IPkcs11Library pkcs11, Pkcs11Options options) { var slots = pkcs11.GetSlotList(SlotsType.WithTokenPresent); if (slots.Count == 0) diff --git a/tests/AirGap/StellaOps.AirGap.Controller.Tests/AirGapStateServiceTests.cs b/tests/AirGap/StellaOps.AirGap.Controller.Tests/AirGapStateServiceTests.cs index 9eed433a5..e0a0074fd 100644 --- a/tests/AirGap/StellaOps.AirGap.Controller.Tests/AirGapStateServiceTests.cs +++ b/tests/AirGap/StellaOps.AirGap.Controller.Tests/AirGapStateServiceTests.cs @@ -48,4 +48,73 @@ public class AirGapStateServiceTests Assert.False(status.State.Sealed); Assert.Equal(later, status.State.LastTransitionAt); } + + [Fact] + public async Task Seal_persists_drift_baseline_seconds() + { + var now = DateTimeOffset.UtcNow; + var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest"); + var budget = StalenessBudget.Default; + + var state = await _service.SealAsync("tenant-drift", "policy-drift", anchor, budget, now); + + Assert.Equal(300, state.DriftBaselineSeconds); // 5 minutes = 300 seconds + } + + [Fact] + public async Task Seal_creates_default_content_budgets_when_not_provided() + { + var now = DateTimeOffset.UtcNow; + var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest"); + var budget = new StalenessBudget(120, 240); + + var state = await _service.SealAsync("tenant-content", "policy-content", anchor, budget, now); + + Assert.Contains("advisories", state.ContentBudgets.Keys); + Assert.Contains("vex", state.ContentBudgets.Keys); + Assert.Contains("policy", state.ContentBudgets.Keys); + Assert.Equal(budget, state.ContentBudgets["advisories"]); + } + + [Fact] + public async Task Seal_uses_provided_content_budgets() + { + var now = DateTimeOffset.UtcNow; + var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest"); + var budget = StalenessBudget.Default; + var contentBudgets = new Dictionary + { + { "advisories", new StalenessBudget(30, 60) }, + { "vex", new StalenessBudget(60, 120) } + }; + + var state = await _service.SealAsync("tenant-custom", "policy-custom", anchor, budget, now, contentBudgets); + + Assert.Equal(new StalenessBudget(30, 60), state.ContentBudgets["advisories"]); + Assert.Equal(new StalenessBudget(60, 120), state.ContentBudgets["vex"]); + Assert.Equal(budget, state.ContentBudgets["policy"]); // Falls back to default + } + + [Fact] + public async Task GetStatus_returns_per_content_staleness() + { + var now = DateTimeOffset.UtcNow; + var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest"); + var budget = StalenessBudget.Default; + var contentBudgets = new Dictionary + { + { "advisories", new StalenessBudget(30, 60) }, + { "vex", new StalenessBudget(60, 120) }, + { "policy", new StalenessBudget(100, 200) } + }; + + await _service.SealAsync("tenant-content-status", "policy-content-status", anchor, budget, now, contentBudgets); + var status = await _service.GetStatusAsync("tenant-content-status", now); + + Assert.NotEmpty(status.ContentStaleness); + Assert.True(status.ContentStaleness["advisories"].IsWarning); // 45s >= 30s warning + Assert.False(status.ContentStaleness["advisories"].IsBreach); // 45s < 60s breach + Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning + Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning + } } diff --git a/tools/nuget-prime/nuget-prime.csproj b/tools/nuget-prime/nuget-prime.csproj index 63750eb00..4e29445ba 100644 --- a/tools/nuget-prime/nuget-prime.csproj +++ b/tools/nuget-prime/nuget-prime.csproj @@ -26,7 +26,6 @@ -