feat: Implement PostgreSQL repositories for various entities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Added BootstrapInviteRepository for managing bootstrap invites.
- Added ClientRepository for handling OAuth/OpenID clients.
- Introduced LoginAttemptRepository for logging login attempts.
- Created OidcTokenRepository for managing OpenIddict tokens and refresh tokens.
- Implemented RevocationExportStateRepository for persisting revocation export state.
- Added RevocationRepository for managing revocations.
- Introduced ServiceAccountRepository for handling service accounts.
This commit is contained in:
master
2025-12-11 17:48:25 +02:00
parent 1995883476
commit ab22181e8b
82 changed files with 5153 additions and 2261 deletions

View File

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

View File

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

View File

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

View File

@@ -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;
/// <summary>
/// Drift baseline in seconds (difference between wall clock and anchor time at seal).
/// </summary>
public long DriftBaselineSeconds { get; init; } = 0;
/// <summary>
/// Per-content staleness budgets (advisories, vex, policy).
/// </summary>
public IReadOnlyDictionary<string, StalenessBudget> ContentBudgets { get; init; } =
new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -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<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, emptyContentStaleness, now);
telemetry.RecordUnseal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}

View File

@@ -11,7 +11,9 @@ public sealed record AirGapStatusResponse(
TimeAnchor TimeAnchor,
StalenessEvaluation Staleness,
long DriftSeconds,
long DriftBaselineSeconds,
long SecondsRemaining,
IReadOnlyDictionary<string, ContentStalenessEntry> 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<string, ContentStalenessEntry> BuildContentStaleness(
IReadOnlyDictionary<string, StalenessEvaluation> evaluations)
{
var result = new Dictionary<string, ContentStalenessEntry>(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);
}

View File

@@ -11,4 +11,10 @@ public sealed class SealRequest
public TimeAnchor? TimeAnchor { get; set; }
public StalenessBudget? StalenessBudget { get; set; }
/// <summary>
/// Optional per-content staleness budgets (advisories, vex, policy).
/// Falls back to StalenessBudget when not provided.
/// </summary>
public Dictionary<string, StalenessBudget>? ContentBudgets { get; set; }
}

View File

@@ -22,11 +22,20 @@ public sealed class AirGapStateService
TimeAnchor timeAnchor,
StalenessBudget budget,
DateTimeOffset nowUtc,
IReadOnlyDictionary<string, StalenessBudget>? 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<string, StalenessBudget> BuildContentBudgets(
IReadOnlyDictionary<string, StalenessBudget>? provided,
StalenessBudget fallback)
{
var result = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
if (provided != null)
{
foreach (var kvp in provided)
{
result[kvp.Key] = kvp.Value;
}
}
public sealed record AirGapStatus(AirGapState State, StalenessEvaluation Staleness, DateTimeOffset EvaluatedAt);
// 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,
IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
DateTimeOffset EvaluatedAt);

View File

@@ -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<MongoLdapClaimsCache>.Instance);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
runner.Dispose();
return Task.CompletedTask;
timeProvider ?? TimeProvider.System);
}
}

View File

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

View File

@@ -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<LdapPluginOptions>(options);
var auditStore = new TestAirgapAuditStore();
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
database,
auditStore,
timeProvider,
NullLogger<LdapClientProvisioningStore>.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<BsonDocument>("ldap_client_provisioning_audit");
var auditRecords = await auditCollection.Find(Builders<BsonDocument>.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<LdapPluginOptions>(options);
var auditStore = new TestAirgapAuditStore();
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
database,
auditStore,
timeProvider,
NullLogger<LdapClientProvisioningStore>.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<BsonDocument>("ldap_client_provisioning_audit");
var auditRecords = await auditCollection.Find(Builders<BsonDocument>.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<LdapClientProvisioningStore>.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<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -234,8 +194,8 @@ public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
return ValueTask.CompletedTask;
}
public ValueTask<bool> 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<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());

View File

@@ -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<BsonDocument>("ldap_bootstrap_audit")
.Find(Builders<BsonDocument>.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<BsonDocument>("ldap_bootstrap_audit")
.CountDocumentsAsync(Builders<BsonDocument>.Filter.Empty);
Assert.Equal(1, auditCount);
Assert.Single(auditStore.Records);
}
private static LdapPluginOptions CreateBaseOptions()
@@ -261,31 +242,17 @@ public class LdapCredentialStoreTests : IDisposable
IOptionsMonitor<LdapPluginOptions> monitor,
FakeLdapConnectionFactory connectionFactory,
Func<TimeSpan, CancellationToken, Task>? delayAsync = null)
=> new(
{
auditStore = new TestAirgapAuditStore();
return new LdapCredentialStore(
PluginName,
monitor,
connectionFactory,
NullLogger<LdapCredentialStore>.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<LdapPluginOptions>

View File

@@ -11,7 +11,11 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugin.Ldap\StellaOps.Authority.Plugin.Ldap.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1" />
</ItemGroup>
</Project>

View File

@@ -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<AuthorityAirgapAuditDocument> Records { get; } = new();
public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Records.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityAirgapAuditDocument>> 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<IReadOnlyList<AuthorityAirgapAuditDocument>>(ordered);
}
public ValueTask<AuthorityAirgapAuditQueryResult> 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));
}
}

View File

@@ -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<string, string?> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -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<string, LdapClaimsCacheEntry> 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<LdapCachedClaims?> 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<LdapCachedClaims?>(null);
}
return ValueTask.FromResult<LdapCachedClaims?>(entry.Claims);
}
return ValueTask.FromResult<LdapCachedClaims?>(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);
}

View File

@@ -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<LdapClaimsCacheDocument> collection;
private readonly LdapClaimsCacheOptions options;
private readonly TimeProvider timeProvider;
private readonly ILogger<MongoLdapClaimsCache> logger;
private readonly TimeSpan entryLifetime;
public MongoLdapClaimsCache(
string pluginName,
IMongoDatabase database,
LdapClaimsCacheOptions cacheOptions,
TimeProvider timeProvider,
ILogger<MongoLdapClaimsCache> 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<LdapClaimsCacheDocument>(collectionName);
EnsureIndexes();
}
public async ValueTask<LdapCachedClaims?> 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<string> roles = document.Roles is { Count: > 0 }
? document.Roles.AsReadOnly()
: Array.Empty<string>();
var attributes = document.Attributes is { Count: > 0 }
? new Dictionary<string, string>(document.Attributes, StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(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<string>(),
Attributes = claims.Attributes?.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase) ?? new Dictionary<string, string>(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<LdapClaimsCacheDocument>.Filter.Empty,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (total < maxEntries)
{
return;
}
var surplus = (int)(total - maxEntries + 1);
var ids = await collection
.Find(Builders<LdapClaimsCacheDocument>.Filter.Empty)
.SortBy(doc => doc.CachedAt)
.Limit(surplus)
.Project(doc => doc.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (ids.Count == 0)
{
return;
}
var deleteFilter = Builders<LdapClaimsCacheDocument>.Filter.In(doc => doc.Id, ids);
await collection.DeleteManyAsync(deleteFilter, cancellationToken).ConfigureAwait(false);
}
private void EnsureIndexes()
{
var expiresIndex = Builders<LdapClaimsCacheDocument>.IndexKeys.Ascending(doc => doc.ExpiresAt);
var indexModel = new CreateIndexModel<LdapClaimsCacheDocument>(
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<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("cachedAt")]
public DateTimeOffset CachedAt { get; set; }
[BsonElement("expiresAt")]
public DateTimeOffset ExpiresAt { get; set; }
}

View File

@@ -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<LdapPluginOptions> optionsMonitor;
private readonly IMongoDatabase mongoDatabase;
private readonly IAuthorityAirgapAuditStore auditStore;
private readonly TimeProvider clock;
private readonly ILogger<LdapClientProvisioningStore> logger;
@@ -42,7 +40,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
IAuthorityRevocationStore revocationStore,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
IMongoDatabase mongoDatabase,
IAuthorityAirgapAuditStore auditStore,
TimeProvider clock,
ILogger<LdapClientProvisioningStore> 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<LdapClientProvisioningAuditDocument>(collectionName);
var record = new LdapClientProvisioningAuditDocument
{
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<string, string?>(StringComparer.OrdinalIgnoreCase)
var properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["senderConstraint"] = document.SenderConstraint,
["plugin"] = pluginName
}
["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<string, string?> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -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<LdapCredentialStore> logger;
private readonly LdapMetrics metrics;
private readonly IMongoDatabase mongoDatabase;
private readonly IAuthorityAirgapAuditStore auditStore;
private readonly TimeProvider timeProvider;
private readonly Func<TimeSpan, CancellationToken, Task> delayAsync;
@@ -37,7 +37,7 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
ILdapConnectionFactory connectionFactory,
ILogger<LdapCredentialStore> logger,
LdapMetrics metrics,
IMongoDatabase mongoDatabase,
IAuthorityAirgapAuditStore auditStore,
TimeProvider timeProvider,
Func<TimeSpan, CancellationToken, Task>? 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<LdapBootstrapAuditDocument>(collectionName);
var document = new LdapBootstrapAuditDocument
{
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<string, string?>(StringComparer.OrdinalIgnoreCase)
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
["email"] = registration.Email
}
["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<string, IReadOnlyCollection<string>> BuildBootstrapAttributes(

View File

@@ -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<ILdapConnectionFactory>(),
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
sp.GetRequiredService<LdapMetrics>(),
sp.GetRequiredService<IMongoDatabase>(),
sp.GetRequiredService<IAuthorityAirgapAuditStore>(),
ResolveTimeProvider(sp)));
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
@@ -60,7 +59,7 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<IAuthorityRevocationStore>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<IMongoDatabase>(),
sp.GetRequiredService<IAuthorityAirgapAuditStore>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILogger<LdapClientProvisioningStore>>()));
@@ -75,12 +74,10 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
return DisabledLdapClaimsCache.Instance;
}
return new MongoLdapClaimsCache(
return new InMemoryLdapClaimsCache(
pluginName,
sp.GetRequiredService<IMongoDatabase>(),
cacheOptions,
ResolveTimeProvider(sp),
sp.GetRequiredService<ILogger<MongoLdapClaimsCache>>());
ResolveTimeProvider(sp));
});
context.Services.AddScoped(sp => new LdapClaimsEnricher(

View File

@@ -20,6 +20,6 @@
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -8,7 +8,6 @@ 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;
@@ -25,8 +24,7 @@ public class StandardPluginRegistrarTests
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
@@ -88,8 +86,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-password-policy");
var configuration = new ConfigurationBuilder()
@@ -134,8 +131,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build();
@@ -167,8 +163,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder()
@@ -202,8 +197,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()

View File

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

View File

@@ -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<string, string?>? 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);
/// <summary>
/// Represents a service account document.
/// </summary>
@@ -28,7 +52,7 @@ public sealed class AuthorityServiceAccountDocument
public bool Enabled { get; set; } = true;
public List<string> AllowedScopes { get; set; } = new();
public List<string> AuthorizedClients { get; set; } = new();
public Dictionary<string, string> Attributes { get; set; } = new();
public Dictionary<string, List<string>> 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<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool Disabled { get; set; }
}
/// <summary>
@@ -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<string>? Scopes { get; set; }
public string? Fingerprint { get; set; }
public Dictionary<string, string?> 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<string> Scopes { get; set; } = new();
public DateTimeOffset OccurredAt { get; set; }
public List<AuthorityLoginAttemptPropertyDocument> 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";
}
/// <summary>
@@ -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<string> 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<string> ActorChain { get; set; } = new();
public string? IncidentReason { get; set; }
public List<string> Devices { get; set; } = new();
public Dictionary<string, string> Properties { get; set; } = new();
public DateTimeOffset? RevokedAt { get; set; }
public string? RevokedReason { get; set; }
public string? RevokedReasonDescription { get; set; }
public IReadOnlyDictionary<string, string?>? RevokedMetadata { get; set; }
}
/// <summary>
@@ -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<AuthorityAirgapAuditPropertyDocument> Properties { get; set; } = new();
}
@@ -165,6 +234,41 @@ public sealed class AuthorityAirgapAuditPropertyDocument
public string Value { get; set; } = string.Empty;
}
/// <summary>
/// Query parameters for airgap audit search.
/// </summary>
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<AuthorityAirgapAuditDocument> items, string? nextCursor)
{
Items = items ?? throw new ArgumentNullException(nameof(items));
NextCursor = nextCursor;
}
public IReadOnlyList<AuthorityAirgapAuditDocument> Items { get; }
public string? NextCursor { get; }
}
/// <summary>
/// Tracks the last exported revocation bundle metadata.
/// </summary>
public sealed class AuthorityRevocationExportStateDocument
{
public long Sequence { get; set; }
public string? BundleId { get; set; }
public DateTimeOffset? IssuedAt { get; set; }
}
/// <summary>
/// Represents a certificate binding for client authentication.
/// </summary>

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Result status for token usage recording.
/// </summary>
public enum TokenUsageUpdateStatus
{
Recorded,
MissingMetadata,
NotFound,
SuspectedReplay
}
/// <summary>
/// Represents the outcome of recording token usage.
/// </summary>
public sealed record TokenUsageUpdateResult(TokenUsageUpdateStatus Status, string? RemoteAddress, string? UserAgent);

View File

@@ -62,5 +62,6 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IAuthorityTokenStore, InMemoryTokenStore>();
services.AddSingleton<IAuthorityRefreshTokenStore, InMemoryRefreshTokenStore>();
services.AddSingleton<IAuthorityAirgapAuditStore, InMemoryAirgapAuditStore>();
services.AddSingleton<IAuthorityRevocationExportStateStore, InMemoryRevocationExportStateStore>();
}
}

View File

@@ -13,6 +13,7 @@ public interface IClientSessionHandle : IDisposable
public interface IAuthorityMongoSessionAccessor
{
IClientSessionHandle? CurrentSession { get; }
ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken);
}
/// <summary>
@@ -21,4 +22,7 @@ public interface IAuthorityMongoSessionAccessor
public sealed class NullAuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public IClientSessionHandle? CurrentSession => null;
public ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult<IClientSessionHandle?>(null);
}

View File

@@ -9,8 +9,10 @@ namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityBootstrapInviteStore
{
ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
@@ -21,6 +23,7 @@ public interface IAuthorityServiceAccountStore
{
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
@@ -62,9 +65,16 @@ public interface IAuthorityTokenStore
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> 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<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<int> 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<IReadOnlyList<AuthorityAirgapAuditDocument>> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
/// <summary>
/// Tracks revocation export state to enforce monotonic bundle sequencing.
/// </summary>
public interface IAuthorityRevocationExportStateStore
{
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpdateAsync(long expectedSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -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<AuthorityBootstrapInviteDocument> 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<bool> ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<BootstrapInviteReservationResult> 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<bool> 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<bool> 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<IReadOnlyList<AuthorityBootstrapInviteDocument>>(expired);
}
}
/// <summary>
@@ -69,6 +124,12 @@ public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
}
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> 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<IReadOnlyList<AuthorityServiceAccountDocument>>(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<IReadOnlyList<AuthorityTokenDocument>>(results);
}
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> 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<IReadOnlyList<AuthorityTokenDocument>>(results);
}
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> 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<IReadOnlyList<AuthorityTokenDocument>>(results);
}
public ValueTask<long> 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<IReadOnlyList<AuthorityTokenDocument>> 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<IReadOnlyList<AuthorityTokenDocument>>(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<string, string?>? 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<bool> 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<int> 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<int> 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<TokenUsageUpdateResult> 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<IReadOnlyList<AuthorityAirgapAuditDocument>>(results);
}
public ValueTask<AuthorityAirgapAuditQueryResult> 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));
}
}
/// <summary>
/// In-memory implementation of the revocation export state store.
/// </summary>
public sealed class InMemoryRevocationExportStateStore : IAuthorityRevocationExportStateStore
{
private readonly SemaphoreSlim gate = new(1, 1);
private AuthorityRevocationExportStateDocument state = new() { Sequence = 0 };
public async ValueTask<AuthorityRevocationExportStateDocument?> 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();
}
}
}

View File

@@ -1,49 +1,44 @@
using System;
using System.Collections.Generic;
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 System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Tests.Infrastructure;
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;
using Microsoft.AspNetCore.TestHost;
namespace StellaOps.Authority.Tests.AdvisoryAi;
public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
private TestLoginAttemptStore? lastLoginAttemptStore;
public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory;
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
{
using var client = CreateClient(
configureOptions: options =>
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(profile: "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<Dictionary<string, string>>();
@@ -54,8 +49,7 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
{
using var client = CreateClient(
configureOptions: options =>
using var client = CreateClient(options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
@@ -67,9 +61,7 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "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<Dictionary<string, string>>();
@@ -80,8 +72,7 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
[Fact]
public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
{
using var client = CreateClient(
configureOptions: options =>
using var client = CreateClient(options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
@@ -89,9 +80,7 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "other-profile"));
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<Dictionary<string, string>>();
@@ -102,8 +91,7 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
[Fact]
public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
{
using var client = CreateClient(
configureOptions: options =>
using var client = CreateClient(options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
@@ -111,14 +99,15 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
var collection = database.GetCollection<BsonDocument>("authority_login_attempts");
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan.");
var payload = CreatePayload("cloud-openai", "Generate remediation plan.");
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
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<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("logged", body!["status"]);
@@ -126,10 +115,9 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
var expectedHash = ComputeSha256(payload.Prompt);
Assert.Equal(expectedHash, body["prompt_hash"]);
var doc = await collection.Find(Builders<BsonDocument>.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync();
Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString);
var properties = ExtractProperties(doc);
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"]);
@@ -139,11 +127,14 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
private HttpClient CreateClient(Action<StellaOpsAuthorityOptions>? configureOptions = null)
{
const string schemeName = "StellaOpsBearer";
var builder = factory.WithWebHostBuilder(hostBuilder =>
{
hostBuilder.ConfigureTestServices(services =>
{
var store = new TestLoginAttemptStore();
lastLoginAttemptStore = store;
services.AddSingleton<IAuthorityLoginAttemptStore>(store);
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = schemeName;
@@ -154,23 +145,40 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
services.PostConfigure<StellaOpsAuthorityOptions>(opts =>
{
opts.Issuer ??= new Uri("https://authority.test");
if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString))
{
opts.Storage.ConnectionString = factory.ConnectionString;
SeedRemoteInferenceEnabled(opts);
SeedTenantConsent(opts);
configureOptions?.Invoke(opts);
});
});
});
var client = builder.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
return client;
}
if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName))
private static RemoteInferenceLogRequest CreatePayload(string profile, string? prompt = null, string taskType = "analysis") => new()
{
opts.Storage.DatabaseName = "authority-tests";
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");
}
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
private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
{
options.Tenants.Clear();
options.Tenants.Add(new AuthorityTenantOptions
{
Id = "tenant-default",
DisplayName = "Tenant Default",
@@ -185,98 +193,52 @@ public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<Autho
}
}
});
configureOptions?.Invoke(opts);
});
});
});
var client = builder.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
return client;
}
private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
private static string ComputeSha256(string? input)
{
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
if (string.IsNullOrWhiteSpace(input))
{
return string.Empty;
}
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));
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static Dictionary<string, string> ExtractProperties(BsonDocument document)
private sealed class RemoteInferenceLogRequest
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (!document.TryGetValue("properties", out var propertiesValue))
{
return result;
[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; }
}
foreach (var item in propertiesValue.AsBsonArray)
private sealed class TestLoginAttemptStore : IAuthorityLoginAttemptStore
{
if (item is not BsonDocument property)
public List<AuthorityLoginAttemptDocument> Records { get; } = new();
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
continue;
Records.Add(document);
return ValueTask.CompletedTask;
}
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;
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(Array.Empty<AuthorityLoginAttemptDocument>());
}
}
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<string, string>
{
["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<string, string> Metadata);
}

View File

@@ -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<AuthorityWebApplic
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z"));
using var client = CreateClient(timeProvider, scopes: $"{StellaOpsScopes.AirgapImport} {StellaOpsScopes.AirgapStatusRead}");
var collection = GetAuditCollection();
await collection.DeleteManyAsync(FilterDefinition<AuthorityAirgapAuditDocument>.Empty);
var request = new AirgapAuditRecordRequestDto
{
@@ -63,7 +61,7 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture<AuthorityWebApplic
Assert.Equal(timeProvider.GetUtcNow(), body.OccurredAt);
Assert.Equal("sha256:abc123", body.Metadata["digest"]);
var stored = await collection.Find(Builders<AuthorityAirgapAuditDocument>.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<AuthorityWebApplic
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-01T12:30:00Z"));
using var client = CreateClient(timeProvider, scopes: $"{StellaOpsScopes.AirgapImport} {StellaOpsScopes.AirgapStatusRead}");
var collection = GetAuditCollection();
await collection.DeleteManyAsync(FilterDefinition<AuthorityAirgapAuditDocument>.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<AuthorityWebApplic
bool includeTestTenantHeader = true)
{
const string schemeName = "StellaOpsBearer";
_airgapStore = null;
var builder = factory.WithWebHostBuilder(hostBuilder =>
{
hostBuilder.ConfigureTestServices(services =>
{
var store = new TestAirgapAuditStore();
_airgapStore = store;
services.Replace(ServiceDescriptor.Singleton<IAuthorityAirgapAuditStore>(store));
services.Replace(ServiceDescriptor.Singleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>());
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
{
@@ -202,11 +202,7 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture<AuthorityWebApplic
return client;
}
private IMongoCollection<AuthorityAirgapAuditDocument> GetAuditCollection()
{
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
return database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
}
private TestAirgapAuditStore? _airgapStore;
private sealed record AirgapAuditRecordRequestDto
{

View File

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

View File

@@ -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<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(invites.FirstOrDefault(i => string.Equals(i.Token, token, StringComparison.Ordinal)));
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> throw new NotImplementedException();
=> ValueTask.FromResult(document);
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));

View File

@@ -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<AuthorityW
foreach (var tokenId in tokenIds)
{
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
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<AuthorityW
{
string? initialId;
DateTimeOffset initialCreatedAt;
bool isInMemoryStore;
using (var firstApp = CreateApplication())
{
@@ -522,6 +522,7 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
Assert.NotNull(document);
initialId = document!.Id;
initialCreatedAt = document.CreatedAt;
isInMemoryStore = store is InMemoryServiceAccountStore;
}
using (var secondApp = CreateApplication())
@@ -531,11 +532,19 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(initialId, document!.Id);
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);
}
}
}
private WebApplicationFactory<Program> CreateApplication(Action<IWebHostBuilder>? configure = null)
{

View File

@@ -6,20 +6,22 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Mongo2Go;
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<Program>, 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";
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,10 +39,11 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
private const string ServiceAccountScope0Key = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__0";
private const string ServiceAccountScope1Key = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__ALLOWEDSCOPES__1";
private const string ServiceAccountAuthorizedClientKey = "STELLAOPS_AUTHORITY_AUTHORITY__DELEGATION__SERVICEACCOUNTS__0__AUTHORIZEDCLIENTS__0";
private const string StorageConnectionKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING";
private const string StorageDatabaseKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__DATABASENAME";
public AuthorityWebApplicationFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true, singleNodeReplSetWaitTimeout: 120);
tempContentRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "stellaops-authority-tests", Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(tempContentRoot);
@@ -52,8 +55,6 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
Environment.SetEnvironmentVariable(IssuerKey, "https://authority.test");
Environment.SetEnvironmentVariable(SchemaVersionKey, "1");
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
@@ -71,10 +72,10 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, "jobs:read");
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, "findings:read");
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, "export-center-worker");
Environment.SetEnvironmentVariable(StorageConnectionKey, "Host=localhost;Username=test;Password=test;Database=authority-tests");
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
}
public string ConnectionString => mongoRunner.ConnectionString;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
@@ -85,7 +86,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["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",
@@ -93,7 +94,25 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
["Authority:Delegation:ServiceAccounts:0:Enabled"] = "true"
});
});
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthorityBootstrapInviteStore>();
services.RemoveAll<IAuthorityServiceAccountStore>();
services.RemoveAll<IAuthorityClientStore>();
services.RemoveAll<IAuthorityRevocationStore>();
services.RemoveAll<IAuthorityLoginAttemptStore>();
services.RemoveAll<IAuthorityTokenStore>();
services.RemoveAll<IAuthorityRefreshTokenStore>();
services.RemoveAll<IAuthorityAirgapAuditStore>();
services.RemoveAll<IAuthorityRevocationExportStateStore>();
services.RemoveAll<IAuthorityMongoSessionAccessor>();
services.AddAuthorityMongoStorage(options =>
{
options.ConnectionString = "mongodb://localhost/authority-tests";
options.DatabaseName = "authority-tests";
});
});
}
protected override IHost CreateHost(IHostBuilder builder)
@@ -104,7 +123,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["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",
@@ -137,12 +156,8 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
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<Progr
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, null);
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, null);
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, null);
Environment.SetEnvironmentVariable(StorageConnectionKey, null);
Environment.SetEnvironmentVariable(StorageDatabaseKey, null);
try
{

View File

@@ -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<AuthorityAirgapAuditDocument> records = new();
public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
records.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityAirgapAuditDocument>> 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<IReadOnlyList<AuthorityAirgapAuditDocument>>(ordered);
}
public ValueTask<AuthorityAirgapAuditQueryResult> 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<AuthorityAirgapAuditDocument> Records => records.ToArray();
}

View File

@@ -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<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(TimeProvider.System);

View File

@@ -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<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(TimeProvider.System);

View File

@@ -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<BsonDocument>
Devices = new List<string>
{
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<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
}
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> 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<IReadOnlyList<AuthorityServiceAccountDocument>>(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<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<AuthorityTokenDocument?>(null);
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Inserted is not null && string.Equals(Inserted.SubjectId, subjectId, StringComparison.OrdinalIgnoreCase) ? new[] { Inserted } : Array.Empty<AuthorityTokenDocument>());
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0L);
public ValueTask<TokenUsageUpdateResult> 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<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> 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<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> InsertAsync(document, cancellationToken, session);
public ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0);
public ValueTask<int> 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<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IClientSessionHandle>(null!);
public IClientSessionHandle? CurrentSession => null;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IClientSessionHandle?>(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
}
};

View File

@@ -13,7 +13,6 @@ 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;
@@ -23,8 +22,10 @@ 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;
@@ -37,6 +38,7 @@ namespace StellaOps.Authority.Tests.OpenIddict;
public class PasswordGrantHandlersTests
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
private readonly TestCredentialAuditContextAccessor auditContextAccessor = new();
[Fact]
public async Task HandlePasswordGrant_EmitsSuccessAuditEvent()
@@ -45,8 +47,8 @@ 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<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance, auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!");
@@ -81,7 +83,8 @@ public class PasswordGrantHandlersTests
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
sealedValidator);
sealedValidator,
auditContextAccessor);
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(CreatePasswordTransaction("alice", "Password1!"));
@@ -188,7 +191,7 @@ public class PasswordGrantHandlersTests
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance);
NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(
registry,
@@ -197,7 +200,7 @@ public class PasswordGrantHandlersTests
sink,
metadataAccessor,
TimeProvider.System,
NullLogger<HandlePasswordGrantHandler>.Instance);
NullLogger<HandlePasswordGrantHandler>.Instance, auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!");
transaction.Options = new OpenIddictServerOptions();
@@ -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<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:publish");
SetParameter(transaction, "policy_reason", "Publish approved policy");
@@ -500,8 +503,8 @@ public class PasswordGrantHandlersTests
var clientDocument = CreateClientDocument(scope);
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance, auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", scope);
SetParameter(transaction, "policy_reason", "Promote approved policy");
@@ -534,7 +537,7 @@ public class PasswordGrantHandlersTests
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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
@@ -543,7 +546,7 @@ public class PasswordGrantHandlersTests
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Pack approval tokens require pack_run_id.", context.ErrorDescription);
Assert.Equal("Pack approval tokens require 'pack_run_id'.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PacksApprove, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
@@ -555,13 +558,13 @@ public class PasswordGrantHandlersTests
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<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.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));
SetParameter(transaction, AuthorityOpenIddictConstants.PackPlanHashParameterName, new string('a', 64));
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(validateContext);
@@ -574,7 +577,7 @@ public class PasswordGrantHandlersTests
var principal = Assert.IsType<ClaimsPrincipal>(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.Equal(new string('a', 64), principal.GetClaim(StellaOpsClaimTypes.PackPlanHash));
Assert.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
@@ -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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.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<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance, auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve");
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
@@ -895,6 +898,31 @@ public class PasswordGrantHandlersTests
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
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);

View File

@@ -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));
private static readonly ActivitySource Activity = new("StellaOps.Authority.Tests.Persistence");
[Fact]
public async Task HandleClientCredentials_PersistsTokenInMongo()
public async Task PersistTokensHandler_StoresAccessTokenMetadata()
{
await ResetCollectionsAsync();
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<PersistTokensHandler>.Instance);
await using var provider = await BuildMongoProviderAsync(clock);
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 clientStore = provider.GetRequiredService<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var serviceAccountStore = provider.GetRequiredService<IAuthorityServiceAccountStore>();
var transaction = new OpenIddictServerTransaction
{
Request = new OpenIddictRequest(),
Options = new OpenIddictServerOptions()
};
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<IAuthorityMongoSessionAccessor>();
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(
clientStore,
registry,
TestActivitySource,
authSink,
metadataAccessor,
serviceAccountStore,
tokenStore,
clock,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, metadataAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.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<ClaimsPrincipal>(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)
var context = 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<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
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<string> { "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<IAuthorityMongoSessionAccessor>();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
clientStore,
registry,
metadataAccessor,
auditSink,
clock,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateAccessTokenHandler>.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 tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None);
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);
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_FlagsSuspectedReplay_OnNewDeviceFingerprint()
public async Task RecordUsageAsync_FlagsReplayOnNewFingerprint()
{
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<IAuthorityTokenStore>();
var tokenDocument = new AuthorityTokenDocument
var tokenStore = new InMemoryTokenStore();
var token = new AuthorityTokenDocument
{
TokenId = "token-replay",
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
ClientId = "client-1",
TokenType = OpenIddictConstants.TokenTypeHints.AccessToken,
Status = "valid",
CreatedAt = issuedAt,
Devices = new List<BsonDocument>
{
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 }
}
}
CreatedAt = DateTimeOffset.UtcNow
};
await tokenStore.InsertAsync(tokenDocument, CancellationToken.None);
await tokenStore.InsertAsync(token, CancellationToken.None);
var result = await tokenStore.RecordUsageAsync(
"token-replay",
remoteAddress: "10.0.0.2",
userAgent: "agent/2.0",
observedAt: clock.GetUtcNow(),
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.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<IAuthorityTokenStore>();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
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<string> { "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<BsonDocument>(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<BsonDocument>(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<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
await tokens.DeleteManyAsync(Builders<AuthorityTokenDocument>.Filter.Empty);
var clients = fixture.Database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
await clients.DeleteManyAsync(Builders<AuthorityClientDocument>.Filter.Empty);
}
private async Task<ServiceProvider> BuildMongoProviderAsync(FakeTimeProvider clock)
{
var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(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<AuthorityMongoInitializer>();
var database = provider.GetRequiredService<IMongoDatabase>();
await initializer.InitialiseAsync(database, CancellationToken.None);
return provider;
Assert.Equal(TokenUsageUpdateStatus.Recorded, first.Status);
Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, second.Status);
}
}

View File

@@ -4,6 +4,8 @@ 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;
@@ -113,6 +115,7 @@ public sealed class VulnPermalinkServiceTests
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton<IConfiguration>(_ => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build());
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(timeProvider);

View File

@@ -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<byte> data)
{
using var sha = SHA256.Create();
return sha.ComputeHash(data.ToArray());
}
public string ComputeHashHex(ReadOnlySpan<byte> 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<string, string?>(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<string, string?>(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)
};

View File

@@ -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<IHostEnvironment>(new TestHostEnvironment(basePath));
services.AddSingleton<IConfiguration>(_ => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build());
services.AddSingleton(options);
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
services.AddSingleton(TimeProvider.System);

View File

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

View File

@@ -11,7 +11,6 @@ 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;
@@ -85,7 +84,7 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
var document = new AuthorityTokenDocument
{
TokenId = tokenId,
Type = tokenType,
TokenType = tokenType,
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
Scope = scopes,

View File

@@ -12,7 +12,6 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using MongoDB.Driver;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;

View File

@@ -32,10 +32,11 @@ using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugins;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Console;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Postgres;
using StellaOps.Authority.Storage.PostgresAdapters;
using StellaOps.Authority.RateLimiting;
using StellaOps.Configuration;
using StellaOps.Plugin.DependencyInjection;
@@ -240,12 +241,24 @@ var pluginHostOptions = BuildPluginHostOptions(authorityOptions, builder.Environ
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(authorityConfiguration.Configuration, pluginHostOptions);
builder.Services.AddAuthorityMongoStorage(storageOptions =>
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<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>();
builder.Services.TryAddScoped<IAuthorityBootstrapInviteStore, PostgresBootstrapInviteStore>();
builder.Services.TryAddScoped<IAuthorityServiceAccountStore, PostgresServiceAccountStore>();
builder.Services.TryAddScoped<IAuthorityClientStore, PostgresClientStore>();
builder.Services.TryAddScoped<IAuthorityRevocationStore, PostgresRevocationStore>();
builder.Services.TryAddScoped<IAuthorityLoginAttemptStore, PostgresLoginAttemptStore>();
builder.Services.TryAddScoped<IAuthorityTokenStore, PostgresTokenStore>();
builder.Services.TryAddScoped<IAuthorityRefreshTokenStore, PostgresTokenStore>();
builder.Services.TryAddScoped<IAuthorityAirgapAuditStore, PostgresAirgapAuditStore>();
builder.Services.TryAddScoped<IAuthorityRevocationExportStateStore, PostgresRevocationExportStateStore>();
builder.Services.AddSingleton<IAuthorityIdentityProviderRegistry, AuthorityIdentityProviderRegistry>();
builder.Services.AddSingleton<IAuthEventSink, AuthorityAuditSink>();
@@ -399,10 +412,6 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
var app = builder.Build();
// Initialize storage (Mongo shim delegates to PostgreSQL migrations)
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
await mongoInitializer.InitialiseAsync(null!, CancellationToken.None);
var serviceAccountStore = app.Services.GetRequiredService<IAuthorityServiceAccountStore>();
if (authorityOptions.Delegation.ServiceAccounts.Count > 0)
{

View File

@@ -140,7 +140,7 @@ internal sealed class RevocationBundleBuilder
var metadata = document.RevokedMetadata is null
? null
: new SortedDictionary<string, string?>(document.RevokedMetadata, StringComparer.OrdinalIgnoreCase);
: new SortedDictionary<string, string?>(document.RevokedMetadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase);
yield return new RevocationEntryModel
{

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityAirgapAuditStore"/>.
/// </summary>
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<AirgapAuditPropertyEntity>();
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<IReadOnlyList<AuthorityAirgapAuditDocument>> 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<AuthorityAirgapAuditQueryResult> 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<AirgapAuditPropertyEntity> properties, string name)
{
var property = properties.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
return property?.Value;
}
}

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityBootstrapInviteStore"/>.
/// </summary>
internal sealed class PostgresBootstrapInviteStore : IAuthorityBootstrapInviteStore
{
private readonly BootstrapInviteRepository repository;
public PostgresBootstrapInviteStore(BootstrapInviteRepository repository)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async ValueTask<AuthorityBootstrapInviteDocument?> 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<AuthorityBootstrapInviteDocument> 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<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(document.Metadata, StringComparer.OrdinalIgnoreCase)
};
await repository.InsertAsync(entity, cancellationToken).ConfigureAwait(false);
return Map(entity);
}
public async ValueTask<BootstrapInviteReservationResult> 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<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> await repository.ReleaseAsync(token, cancellationToken).ConfigureAwait(false);
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> await repository.ConsumeAsync(token, consumedBy, consumedAt, cancellationToken).ConfigureAwait(false);
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> 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<string, string?>(entity.Metadata, StringComparer.OrdinalIgnoreCase)
};
}

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityClientStore"/>.
/// </summary>
internal sealed class PostgresClientStore : IAuthorityClientStore
{
private readonly ClientRepository repository;
public PostgresClientStore(ClientRepository repository)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async ValueTask<AuthorityClientDocument?> 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<bool> 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
};
}

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityLoginAttemptStore"/>.
/// </summary>
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<LoginAttemptPropertyEntity>();
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<IReadOnlyList<AuthorityLoginAttemptDocument>> 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<string, LoginAttemptPropertyEntity> properties, string key)
=> properties.TryGetValue(key, out var property) ? property.Value : null;
private static List<string> ParseScopes(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.OrderBy(scope => scope, StringComparer.Ordinal)
.ToList();
}
}

View File

@@ -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<AuthorityRevocationExportStateDocument?> 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
};
}

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityRevocationStore"/>.
/// </summary>
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<IReadOnlyList<AuthorityRevocationDocument>> 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)
};
}

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityServiceAccountStore"/>.
/// </summary>
internal sealed class PostgresServiceAccountStore : IAuthorityServiceAccountStore
{
private readonly ServiceAccountRepository repository;
public PostgresServiceAccountStore(ServiceAccountRepository repository)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async ValueTask<AuthorityServiceAccountDocument?> 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<IReadOnlyList<AuthorityServiceAccountDocument>> 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<IReadOnlyList<AuthorityServiceAccountDocument>> 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<bool> 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
};
}

View File

@@ -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;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IAuthorityTokenStore"/> and <see cref="IAuthorityRefreshTokenStore"/>.
/// </summary>
internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefreshTokenStore
{
private readonly OidcTokenRepository repository;
private readonly ConcurrentDictionary<string, HashSet<string>> deviceFingerprints = new(StringComparer.OrdinalIgnoreCase);
public PostgresTokenStore(OidcTokenRepository repository)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async ValueTask<AuthorityTokenDocument?> 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<AuthorityTokenDocument?> 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<IReadOnlyList<AuthorityTokenDocument>> 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<IReadOnlyList<AuthorityTokenDocument>> 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<IReadOnlyList<AuthorityTokenDocument>> 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<long> 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<IReadOnlyList<AuthorityTokenDocument>> 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<bool> 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<string, string?>? 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<int> 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<TokenUsageUpdateResult> 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<string>(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<AuthorityRefreshTokenDocument?> 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<AuthorityRefreshTokenDocument?> 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<bool> ConsumeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
return await repository.ConsumeRefreshTokenAsync(tokenId, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<int> 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<string>();
List<string> actorChain = new();
if (properties.TryGetValue("actor_chain", out var actorJson) && !string.IsNullOrWhiteSpace(actorJson))
{
try
{
actorChain = JsonSerializer.Deserialize<List<string>>(actorJson) ?? new List<string>();
}
catch (JsonException)
{
actorChain = new List<string>();
}
}
List<string> devices = new();
if (properties.TryGetValue("devices", out var devicesJson) && !string.IsNullOrWhiteSpace(devicesJson))
{
try
{
devices = JsonSerializer.Deserialize<List<string>>(devicesJson) ?? new List<string>();
}
catch (JsonException)
{
devices = new List<string>();
}
}
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<string, string> BuildProperties(AuthorityTokenDocument document)
{
var properties = new Dictionary<string, string>(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<string, string> properties, string key, string? defaultValue)
{
if (properties.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
return defaultValue;
}
private static DateTimeOffset? ParseDate(IReadOnlyDictionary<string, string> properties, string key)
{
if (properties.TryGetValue(key, out var value) && DateTimeOffset.TryParse(value, out var parsed))
{
return parsed;
}
return null;
}
private static IReadOnlyDictionary<string, string?>? ExtractRevokedMetadata(IReadOnlyDictionary<string, string> 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
};
}

View File

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

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an air-gapped audit record.
/// </summary>
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<AirgapAuditPropertyEntity> Properties { get; init; } = Array.Empty<AirgapAuditPropertyEntity>();
}
/// <summary>
/// Represents a property stored alongside an airgap audit record.
/// </summary>
public sealed class AirgapAuditPropertyEntity
{
public required string Name { get; init; }
public required string Value { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a bootstrap invite seed.
/// </summary>
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<string, string?> Metadata { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an OAuth/OpenID Connect client configuration.
/// </summary>
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<string> RedirectUris { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> PostLogoutRedirectUris { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> AllowedScopes { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> AllowedGrantTypes { get; init; } = Array.Empty<string>();
public bool RequireClientSecret { get; init; }
public bool RequirePkce { get; init; }
public bool AllowPlainTextPkce { get; init; }
public string? ClientType { get; init; }
public IReadOnlyDictionary<string, string> Properties { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ClientCertificateBindingEntity> CertificateBindings { get; init; } = Array.Empty<ClientCertificateBindingEntity>();
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Represents a certificate binding for mutual TLS clients.
/// </summary>
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<string> SubjectAlternativeNames { get; init; } = Array.Empty<string>();
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; }
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a login attempt.
/// </summary>
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<LoginAttemptPropertyEntity> Properties { get; init; } = Array.Empty<LoginAttemptPropertyEntity>();
}
/// <summary>
/// Represents a property attached to a login attempt.
/// </summary>
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";
}

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents an OpenIddict token persisted in PostgreSQL.
/// </summary>
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<string, string> Properties { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Represents a refresh token persisted in PostgreSQL.
/// </summary>
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; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a revocation record.
/// </summary>
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<string, string?> Metadata { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents the last exported revocation bundle metadata.
/// </summary>
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; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
/// <summary>
/// Represents a service account configuration.
/// </summary>
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<string> AllowedScopes { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> AuthorizedClients { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, List<string>> Attributes { get; init; } = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for airgap audit records.
/// </summary>
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public AirgapAuditRepository(AuthorityDataSource dataSource, ILogger<AirgapAuditRepository> 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<IReadOnlyList<AirgapAuditEntity>> 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<DateTimeOffset>(6),
Properties = DeserializeProperties(reader, 7)
};
private static IReadOnlyList<AirgapAuditPropertyEntity> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return Array.Empty<AirgapAuditPropertyEntity>();
}
var json = reader.GetString(ordinal);
List<AirgapAuditPropertyEntity>? parsed = JsonSerializer.Deserialize<List<AirgapAuditPropertyEntity>>(json, SerializerOptions);
return parsed ?? new List<AirgapAuditPropertyEntity>();
}
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for bootstrap invites.
/// </summary>
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public BootstrapInviteRepository(AuthorityDataSource dataSource, ILogger<BootstrapInviteRepository> logger)
: base(dataSource, logger)
{
}
public async Task<BootstrapInviteEntity?> 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<bool> 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<bool> 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<bool> 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<IReadOnlyList<BootstrapInviteEntity>> 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<DateTimeOffset>(5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6),
IssuedAt = reader.GetFieldValue<DateTimeOffset>(7),
IssuedBy = GetNullableString(reader, 8),
ReservedUntil = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
ReservedBy = GetNullableString(reader, 10),
Consumed = reader.GetBoolean(11),
Status = GetNullableString(reader, 12) ?? "pending",
Metadata = DeserializeMetadata(reader, 13)
};
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions)
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for OAuth/OpenID clients.
/// </summary>
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public ClientRepository(AuthorityDataSource dataSource, ILogger<ClientRepository> logger)
: base(dataSource, logger)
{
}
public async Task<ClientEntity?> 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<bool> 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<string[]>(9),
PostLogoutRedirectUris = reader.GetFieldValue<string[]>(10),
AllowedScopes = reader.GetFieldValue<string[]>(11),
AllowedGrantTypes = reader.GetFieldValue<string[]>(12),
RequireClientSecret = reader.GetBoolean(13),
RequirePkce = reader.GetBoolean(14),
AllowPlainTextPkce = reader.GetBoolean(15),
ClientType = GetNullableString(reader, 16),
Properties = DeserializeDictionary(reader, 17),
CertificateBindings = Deserialize<List<ClientCertificateBindingEntity>>(reader, 18) ?? new List<ClientCertificateBindingEntity>(),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(20)
};
private static IReadOnlyDictionary<string, string> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, SerializerOptions) ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
private static T? Deserialize<T>(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return default;
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<T>(json, SerializerOptions);
}
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for login attempts.
/// </summary>
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public LoginAttemptRepository(AuthorityDataSource dataSource, ILogger<LoginAttemptRepository> 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<IReadOnlyList<LoginAttemptEntity>> 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<DateTimeOffset>(8),
Properties = DeserializeProperties(reader, 9)
};
private static IReadOnlyList<LoginAttemptPropertyEntity> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return Array.Empty<LoginAttemptPropertyEntity>();
}
var json = reader.GetString(ordinal);
List<LoginAttemptPropertyEntity>? parsed = JsonSerializer.Deserialize<List<LoginAttemptPropertyEntity>>(json, SerializerOptions);
return parsed ?? new List<LoginAttemptPropertyEntity>();
}
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for OpenIddict tokens and refresh tokens.
/// </summary>
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger)
: base(dataSource, logger)
{
}
public async Task<OidcTokenEntity?> 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<OidcTokenEntity?> 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<IReadOnlyList<OidcTokenEntity>> 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<IReadOnlyList<OidcTokenEntity>> 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<bool> 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<int> 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<int> 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<OidcRefreshTokenEntity?> 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<OidcRefreshTokenEntity?> 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<bool> 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<int> 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<DateTimeOffset>(6),
ExpiresAt = reader.IsDBNull(7) ? null : reader.GetFieldValue<DateTimeOffset>(7),
RedeemedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(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<DateTimeOffset>(5),
ExpiresAt = reader.IsDBNull(6) ? null : reader.GetFieldValue<DateTimeOffset>(6),
ConsumedAt = reader.IsDBNull(7) ? null : reader.GetFieldValue<DateTimeOffset>(7),
Payload = GetNullableString(reader, 8)
};
private static IReadOnlyDictionary<string, string> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, SerializerOptions) ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -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;
/// <summary>
/// Repository that persists revocation export sequence state.
/// </summary>
public sealed class RevocationExportStateRepository : RepositoryBase<AuthorityDataSource>
{
public RevocationExportStateRepository(AuthorityDataSource dataSource, ILogger<RevocationExportStateRepository> logger)
: base(dataSource, logger)
{
}
public async Task<RevocationExportStateEntity?> 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<DateTimeOffset>(3)
};
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for revocations.
/// </summary>
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public RevocationRepository(AuthorityDataSource dataSource, ILogger<RevocationRepository> 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<IReadOnlyList<RevocationEntity>> 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<DateTimeOffset>(8),
EffectiveAt = reader.GetFieldValue<DateTimeOffset>(9),
ExpiresAt = reader.IsDBNull(10) ? null : reader.GetFieldValue<DateTimeOffset>(10),
Metadata = DeserializeMetadata(reader, 11)
};
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
Dictionary<string, string?>? parsed = JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions);
return parsed ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -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;
/// <summary>
/// PostgreSQL repository for service accounts.
/// </summary>
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
public ServiceAccountRepository(AuthorityDataSource dataSource, ILogger<ServiceAccountRepository> logger)
: base(dataSource, logger)
{
}
public async Task<ServiceAccountEntity?> 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<IReadOnlyList<ServiceAccountEntity>> 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<bool> 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<string[]>(6),
AuthorizedClients = reader.GetFieldValue<string[]>(7),
Attributes = ReadDictionary(reader, 8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
private static IReadOnlyDictionary<string, List<string>> ReadDictionary(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
var dictionary = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<string>>>(json) ??
new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
return dictionary;
}
}

View File

@@ -66,5 +66,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuditRepository>(sp => sp.GetRequiredService<AuditRepository>());
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
// Mongo-store equivalents (PostgreSQL-backed)
services.AddScoped<BootstrapInviteRepository>();
services.AddScoped<ServiceAccountRepository>();
services.AddScoped<ClientRepository>();
services.AddScoped<RevocationRepository>();
services.AddScoped<LoginAttemptRepository>();
services.AddScoped<OidcTokenRepository>();
services.AddScoped<AirgapAuditRepository>();
services.AddScoped<RevocationExportStateRepository>();
}
}

View File

@@ -44,8 +44,8 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<Compile Include="$(ConcelierSharedTestsPath)AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<Compile Include="$(ConcelierSharedTestsPath)MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />

View File

@@ -15,7 +15,6 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
</ItemGroup>
</Project>

View File

@@ -12,7 +12,6 @@
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />

View File

@@ -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<string, ObjectAttribute[]> _attributeCache = new(StringComparer.Ordinal);
private readonly Pkcs11InteropFactories _factories;
private readonly IPkcs11Library _library;
private readonly ISlot _slot;
private readonly ConcurrentDictionary<string, IObjectAttribute[]> _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<ObjectAttribute>
var template = new List<IObjectAttribute>
{
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<CKA> { type })
?.Select(attr => new ObjectAttribute(attr.Type, attr.GetValueAsByteArray()))
.ToArray() ?? Array.Empty<ObjectAttribute>();
?.ToArray() ?? Array.Empty<IObjectAttribute>();
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)

View File

@@ -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<string, StalenessBudget>
{
{ "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<string, StalenessBudget>
{
{ "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
}
}

View File

@@ -26,7 +26,6 @@
<PackageDownload Include="Microsoft.Extensions.Logging.Abstractions" Version="[10.0.0-rc.2.25502.107]" />
<PackageDownload Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="[10.0.0-rc.2.25502.107]" />
<PackageDownload Include="Microsoft.Extensions.Options" Version="[10.0.0-rc.2.25502.107]" />
<PackageDownload Include="MongoDB.Driver" Version="[3.5.0]" />
<PackageDownload Include="NATS.Client.Core" Version="[2.0.0]" />
<PackageDownload Include="NATS.Client.JetStream" Version="[2.0.0]" />
<PackageDownload Include="RoaringBitmap" Version="[0.0.9]" />