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

@@ -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;
}
}
// Ensure common keys exist with fallback
foreach (var key in new[] { "advisories", "vex", "policy" })
{
if (!result.ContainsKey(key))
{
result[key] = fallback;
}
}
return result;
}
}
public sealed record AirGapStatus(AirGapState State, StalenessEvaluation Staleness, DateTimeOffset EvaluatedAt);
public sealed record AirGapStatus(
AirGapState State,
StalenessEvaluation Staleness,
IReadOnlyDictionary<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
var properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
Plugin = pluginName,
ClientId = document.ClientId,
DistinguishedName = BuildDistinguishedName(document.ClientId, options),
Operation = operation,
SecretHash = document.SecretHash,
Tenant = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
Project = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
Timestamp = clock.GetUtcNow(),
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["senderConstraint"] = document.SenderConstraint,
["plugin"] = pluginName
}
["senderConstraint"] = document.SenderConstraint,
["plugin"] = pluginName,
["distinguishedName"] = BuildDistinguishedName(document.ClientId, options),
["collection"] = collectionName,
["tenant"] = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
["project"] = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
["secretHash"] = document.SecretHash
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
var auditDocument = new AuthorityAirgapAuditDocument
{
EventType = $"ldap.client.{operation}",
OperatorId = pluginName,
ComponentId = collectionName,
Outcome = "success",
OccurredAt = clock.GetUtcNow(),
Properties = properties
.Where(kv => !string.IsNullOrWhiteSpace(kv.Value) || kv.Key is "plugin" or "collection" or "distinguishedName")
.Select(kv => new AuthorityAirgapAuditPropertyDocument
{
Name = kv.Key,
Value = kv.Value ?? string.Empty
})
.ToList()
};
await auditStore.InsertAsync(auditDocument, cancellationToken, session: null).ConfigureAwait(false);
}
private async Task RecordRevocationAsync(string clientId, CancellationToken cancellationToken)
@@ -429,39 +436,3 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
private LdapClientProvisioningOptions GetProvisioningOptions()
=> optionsMonitor.Get(pluginName).ClientProvisioning;
}
internal sealed class LdapClientProvisioningAuditDocument
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("clientId")]
public string ClientId { get; set; } = string.Empty;
[BsonElement("dn")]
public string DistinguishedName { get; set; } = string.Empty;
[BsonElement("operation")]
public string Operation { get; set; } = string.Empty;
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]
public string? Project { get; set; }
[BsonElement("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[BsonElement("metadata")]
public Dictionary<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
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
Plugin = pluginName,
Username = NormalizeUsername(registration.Username),
DistinguishedName = distinguishedName,
Operation = "upsert",
SecretHash = string.IsNullOrWhiteSpace(registration.Password)
? null
: AuthoritySecretHasher.ComputeHash(registration.Password!),
Timestamp = timeProvider.GetUtcNow(),
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
["email"] = registration.Email
}
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
["email"] = registration.Email,
["dn"] = distinguishedName,
["collection"] = collectionName,
["username"] = NormalizeUsername(registration.Username)
};
foreach (var attribute in registration.Attributes)
{
document.Metadata[$"attr.{attribute.Key}"] = attribute.Value;
metadata[$"attr.{attribute.Key}"] = attribute.Value;
}
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
var auditDocument = new AuthorityAirgapAuditDocument
{
EventType = "ldap.bootstrap.upsert",
OperatorId = pluginName,
ComponentId = collectionName,
Outcome = "success",
OccurredAt = timeProvider.GetUtcNow(),
Properties = metadata.Select(pair => new AuthorityAirgapAuditPropertyDocument
{
Name = pair.Key,
Value = pair.Value ?? string.Empty
}).ToList()
};
await auditStore.InsertAsync(auditDocument, cancellationToken, session: null).ConfigureAwait(false);
}
private IReadOnlyDictionary<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

@@ -1,329 +1,323 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginRegistrarTests
{
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "8",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false",
["lockout:enabled"] = "false",
["passwordHashing:memorySizeInKib"] = "8192",
["passwordHashing:iterations"] = "2",
["passwordHashing:parallelism"] = "1",
["bootstrapUser:username"] = "bootstrap",
["bootstrapUser:password"] = "Bootstrap1!",
["bootstrapUser:requirePasswordReset"] = "true"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
new Dictionary<string, string?>(),
"standard.yaml");
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginRegistrarTests
{
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "8",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false",
["lockout:enabled"] = "false",
["passwordHashing:memorySizeInKib"] = "8192",
["passwordHashing:iterations"] = "2",
["passwordHashing:parallelism"] = "1",
["bootstrapUser:username"] = "bootstrap",
["bootstrapUser:password"] = "Bootstrap1!",
["bootstrapUser:requirePasswordReset"] = "true"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
foreach (var hosted in hostedServices)
{
if (hosted is StandardPluginBootstrapper bootstrapper)
{
await bootstrapper.StartAsync(CancellationToken.None);
}
}
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
foreach (var hosted in hostedServices)
{
if (hosted is StandardPluginBootstrapper bootstrapper)
{
await bootstrapper.StartAsync(CancellationToken.None);
}
}
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
Assert.False(plugin.Capabilities.SupportsMfa);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.True(verification.User?.RequiresPasswordReset);
}
[Fact]
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-password-policy");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "6",
["passwordPolicy:requireUppercase"] = "false",
["passwordPolicy:requireLowercase"] = "false",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
Assert.False(plugin.Capabilities.SupportsMfa);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.True(verification.User?.RequiresPasswordReset);
}
[Fact]
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
{
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-password-policy");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "6",
["passwordPolicy:requireUppercase"] = "false",
["passwordPolicy:requireLowercase"] = "false",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
_ = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
Assert.Contains(loggerProvider.Entries, entry =>
entry.Level == LogLevel.Warning &&
entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
Array.Empty<string>(),
new Dictionary<string, string?>(),
"standard.yaml");
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
_ = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
Assert.Contains(loggerProvider.Entries, entry =>
entry.Level == LogLevel.Warning &&
entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
Array.Empty<string>(),
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
}
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["bootstrapUser:username"] = "bootstrap"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
Assert.Throws<InvalidOperationException>(() => scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>());
}
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["tokenSigning:keyDirectory"] = "../keys"
})
.Build();
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
}
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["bootstrapUser:username"] = "bootstrap"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
Assert.Throws<InvalidOperationException>(() => scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>());
}
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
var client = new InMemoryMongoClient();
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["tokenSigning:keyDirectory"] = "../keys"
})
.Build();
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
}
internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<CapturedLogEntry> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly string category;
private readonly List<CapturedLogEntry> entries;
public CapturingLogger(string category, List<CapturedLogEntry> entries)
{
this.category = category;
this.entries = entries;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose()
{
}
}
}
}
internal sealed class StubRevocationStore : IAuthorityRevocationStore
{
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
}
internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<CapturedLogEntry> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly string category;
private readonly List<CapturedLogEntry> entries;
public CapturingLogger(string category, List<CapturedLogEntry> entries)
{
this.category = category;
this.entries = entries;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose()
{
}
}
}
}
internal sealed class StubRevocationStore : IAuthorityRevocationStore
{
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}

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,282 +1,244 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using Xunit;
using Microsoft.AspNetCore.TestHost;
namespace StellaOps.Authority.Tests.AdvisoryAi;
public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
{
using var client = CreateClient(
configureOptions: options =>
{
options.AdvisoryAi.RemoteInference.Enabled = false;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
});
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "cloud-openai"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("remote_inference_disabled", body!["error"]);
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
{
using var client = CreateClient(
configureOptions: options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "cloud-openai"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("remote_inference_consent_required", body!["error"]);
}
[Fact]
public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
{
using var client = CreateClient(
configureOptions: options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync(
"/advisory-ai/remote-inference/logs",
CreatePayload(profile: "other-profile"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("profile_not_allowed", body!["error"]);
}
[Fact]
public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
{
using var client = CreateClient(
configureOptions: options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
var collection = database.GetCollection<BsonDocument>("authority_login_attempts");
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan.");
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("logged", body!["status"]);
var expectedHash = ComputeSha256(payload.Prompt);
Assert.Equal(expectedHash, body["prompt_hash"]);
var doc = await collection.Find(Builders<BsonDocument>.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync();
Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString);
var properties = ExtractProperties(doc);
Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
}
private HttpClient CreateClient(Action<StellaOpsAuthorityOptions>? configureOptions = null)
{
const string schemeName = "StellaOpsBearer";
var builder = factory.WithWebHostBuilder(hostBuilder =>
{
hostBuilder.ConfigureTestServices(services =>
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = schemeName;
options.DefaultChallengeScheme = schemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(schemeName, _ => { });
services.PostConfigure<StellaOpsAuthorityOptions>(opts =>
{
opts.Issuer ??= new Uri("https://authority.test");
if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString))
{
opts.Storage.ConnectionString = factory.ConnectionString;
}
if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName))
{
opts.Storage.DatabaseName = "authority-tests";
}
opts.AdvisoryAi.RemoteInference.Enabled = true;
opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
opts.Tenants.Clear();
opts.Tenants.Add(new AuthorityTenantOptions
{
Id = "tenant-default",
DisplayName = "Tenant Default",
AdvisoryAi =
{
RemoteInference =
{
ConsentGranted = true,
ConsentVersion = "2025-10",
ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"),
ConsentedBy = "legal@example.com"
}
}
});
configureOptions?.Invoke(opts);
});
});
});
var client = builder.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
return client;
}
private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
{
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
}
private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
{
if (options.Tenants.Count == 0)
{
options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" });
}
var tenant = options.Tenants[0];
tenant.Id = "tenant-default";
tenant.DisplayName = "Tenant Default";
tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10";
tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z");
tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com";
}
private static string ComputeSha256(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static Dictionary<string, string> ExtractProperties(BsonDocument document)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (!document.TryGetValue("properties", out var propertiesValue))
{
return result;
}
foreach (var item in propertiesValue.AsBsonArray)
{
if (item is not BsonDocument property)
{
continue;
}
var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null;
var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null;
if (!string.IsNullOrWhiteSpace(name))
{
result[name] = value ?? string.Empty;
}
}
return result;
}
private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.")
{
return new RemoteInferencePayload(
TaskType: "summary",
Profile: profile,
ModelId: "gpt-4o-mini",
Prompt: prompt,
ContextDigest: "sha256:context",
OutputHash: "sha256:output",
TaskId: "task-123",
Metadata: new Dictionary<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);
}
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Authority.Tests.AdvisoryAi;
public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
private TestLoginAttemptStore? lastLoginAttemptStore;
public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
{
using var client = CreateClient(options =>
{
options.AdvisoryAi.RemoteInference.Enabled = false;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
});
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", CreatePayload("cloud-openai"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("remote_inference_disabled", body!["error"]);
}
[Fact]
public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
{
using var client = CreateClient(options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", CreatePayload("cloud-openai"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("remote_inference_consent_required", body!["error"]);
}
[Fact]
public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
{
using var client = CreateClient(options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", CreatePayload("other-profile"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("profile_not_allowed", body!["error"]);
}
[Fact]
public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
{
using var client = CreateClient(options =>
{
SeedRemoteInferenceEnabled(options);
SeedTenantConsent(options);
});
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
var payload = CreatePayload("cloud-openai", "Generate remediation plan.");
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
if (response.StatusCode != HttpStatusCode.OK)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"Unexpected status {response.StatusCode}: {errorBody}");
}
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(body);
Assert.Equal("logged", body!["status"]);
var expectedHash = ComputeSha256(payload.Prompt);
Assert.Equal(expectedHash, body["prompt_hash"]);
var doc = Assert.Single(lastLoginAttemptStore!.Records.Where(record => record.EventType == "authority.advisory_ai.remote_inference"));
Assert.Equal("authority.advisory_ai.remote_inference", doc.EventType);
var properties = doc.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
}
private HttpClient CreateClient(Action<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;
options.DefaultChallengeScheme = schemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(schemeName, _ => { });
services.PostConfigure<StellaOpsAuthorityOptions>(opts =>
{
opts.Issuer ??= new Uri("https://authority.test");
SeedRemoteInferenceEnabled(opts);
SeedTenantConsent(opts);
configureOptions?.Invoke(opts);
});
});
});
var client = builder.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
return client;
}
private static RemoteInferenceLogRequest CreatePayload(string profile, string? prompt = null, string taskType = "analysis") => new()
{
Profile = profile,
Prompt = prompt ?? "Test prompt",
TaskType = taskType,
PromptHash = null,
PromptAlgorithm = null,
OriginalFileName = "input.jsonl"
};
private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
{
options.AdvisoryAi.RemoteInference.Enabled = true;
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
}
private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
{
options.Tenants.Clear();
options.Tenants.Add(new AuthorityTenantOptions
{
Id = "tenant-default",
DisplayName = "Tenant Default",
AdvisoryAi =
{
RemoteInference =
{
ConsentGranted = true,
ConsentVersion = "2025-10",
ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"),
ConsentedBy = "legal@example.com"
}
}
});
}
private static string ComputeSha256(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return string.Empty;
}
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private sealed class RemoteInferenceLogRequest
{
[JsonPropertyName("profile")]
public string Profile { get; set; } = string.Empty;
[JsonPropertyName("prompt")]
public string? Prompt { get; set; }
[JsonPropertyName("prompt_hash")]
public string? PromptHash { get; set; }
[JsonPropertyName("prompt_algorithm")]
public string? PromptAlgorithm { get; set; }
[JsonPropertyName("original_file_name")]
public string? OriginalFileName { get; set; }
[JsonPropertyName("taskType")]
public string? TaskType { get; set; }
}
private sealed class TestLoginAttemptStore : IAuthorityLoginAttemptStore
{
public List<AuthorityLoginAttemptDocument> Records { get; } = new();
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Records.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(Array.Empty<AuthorityLoginAttemptDocument>());
}
}

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,9 +532,17 @@ public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityW
var document = await store.FindByAccountIdAsync(ServiceAccountId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(initialId, document!.Id);
Assert.Equal(initialCreatedAt, document.CreatedAt);
Assert.True(document.UpdatedAt >= initialCreatedAt);
Assert.Equal(ServiceAccountId, document!.AccountId);
if (isInMemoryStore)
{
Assert.False(string.IsNullOrWhiteSpace(document.Id));
}
else
{
Assert.Equal(initialId, document.Id);
Assert.Equal(initialCreatedAt, document.CreatedAt);
Assert.True(document.UpdatedAt >= initialCreatedAt);
}
}
}

View File

@@ -1,25 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Mongo2Go;
using Xunit;
namespace StellaOps.Authority.Tests.Infrastructure;
public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<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";
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Xunit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Postgres;
namespace StellaOps.Authority.Tests.Infrastructure;
public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly string tempContentRoot;
private const string IssuerKey = "STELLAOPS_AUTHORITY_AUTHORITY__ISSUER";
private const string SchemaVersionKey = "STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION";
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
private const string AckTokensEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
private const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
@@ -37,23 +39,22 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<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";
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);
var repositoryRoot = LocateRepositoryRoot();
var openApiSource = Path.Combine(repositoryRoot, "src", "Api", "StellaOps.Api.OpenApi", "authority", "openapi.yaml");
var openApiDestination = Path.Combine(tempContentRoot, "OpenApi", "authority.yaml");
Directory.CreateDirectory(Path.GetDirectoryName(openApiDestination)!);
File.Copy(openApiSource, openApiDestination, overwrite: true);
Environment.SetEnvironmentVariable(IssuerKey, "https://authority.test");
Environment.SetEnvironmentVariable(SchemaVersionKey, "1");
Environment.SetEnvironmentVariable(StorageConnectionKey, mongoRunner.ConnectionString);
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
private const string StorageConnectionKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING";
private const string StorageDatabaseKey = "STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__DATABASENAME";
public AuthorityWebApplicationFactory()
{
tempContentRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "stellaops-authority-tests", Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(tempContentRoot);
var repositoryRoot = LocateRepositoryRoot();
var openApiSource = Path.Combine(repositoryRoot, "src", "Api", "StellaOps.Api.OpenApi", "authority", "openapi.yaml");
var openApiDestination = Path.Combine(tempContentRoot, "OpenApi", "authority.yaml");
Directory.CreateDirectory(Path.GetDirectoryName(openApiDestination)!);
File.Copy(openApiSource, openApiDestination, overwrite: true);
Environment.SetEnvironmentVariable(IssuerKey, "https://authority.test");
Environment.SetEnvironmentVariable(SchemaVersionKey, "1");
Environment.SetEnvironmentVariable(SigningEnabledKey, "false");
Environment.SetEnvironmentVariable(AckTokensEnabledKey, "false");
Environment.SetEnvironmentVariable(WebhooksEnabledKey, "false");
@@ -71,78 +72,92 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, "jobs:read");
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, "findings:read");
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, "export-center-worker");
}
public string ConnectionString => mongoRunner.ConnectionString;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.UseContentRoot(tempContentRoot);
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["Authority:Storage:DatabaseName"] = "authority-tests",
Environment.SetEnvironmentVariable(StorageConnectionKey, "Host=localhost;Username=test;Password=test;Database=authority-tests");
Environment.SetEnvironmentVariable(StorageDatabaseKey, "authority-tests");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.UseContentRoot(tempContentRoot);
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = "Host=localhost;Username=test;Password=test;Database=authority-tests",
["Authority:Storage:DatabaseName"] = "authority-tests",
["Authority:Signing:Enabled"] = "false",
["Authority:Notifications:AckTokens:Enabled"] = "false",
["Authority:Notifications:Webhooks:Enabled"] = "false",
["Authority:Delegation:ServiceAccounts:0:Enabled"] = "true"
});
});
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureHostConfiguration(configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["Authority:Storage:DatabaseName"] = "authority-tests",
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)
{
builder.ConfigureHostConfiguration(configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:SchemaVersion"] = "1",
["Authority:Storage:ConnectionString"] = "Host=localhost;Username=test;Password=test;Database=authority-tests",
["Authority:Storage:DatabaseName"] = "authority-tests",
["Authority:Signing:Enabled"] = "false",
["Authority:Notifications:AckTokens:Enabled"] = "false",
["Authority:Notifications:Webhooks:Enabled"] = "false",
["Authority:Delegation:ServiceAccounts:0:Enabled"] = "true"
});
});
return base.CreateHost(builder);
}
public Task InitializeAsync() => Task.CompletedTask;
private static string LocateRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = directory.FullName;
if (File.Exists(Path.Combine(candidate, "README.md")) && Directory.Exists(Path.Combine(candidate, "src")))
{
return candidate;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Failed to locate repository root for Authority tests.");
}
return base.CreateHost(builder);
}
public Task InitializeAsync() => Task.CompletedTask;
private static string LocateRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = directory.FullName;
if (File.Exists(Path.Combine(candidate, "README.md")) && Directory.Exists(Path.Combine(candidate, "src")))
{
return candidate;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Failed to locate repository root for Authority tests.");
}
public override async ValueTask DisposeAsync()
{
mongoRunner.Dispose();
Environment.SetEnvironmentVariable(IssuerKey, null);
Environment.SetEnvironmentVariable(SchemaVersionKey, null);
Environment.SetEnvironmentVariable(StorageConnectionKey, null);
Environment.SetEnvironmentVariable(StorageDatabaseKey, null);
Environment.SetEnvironmentVariable(SigningEnabledKey, null);
Environment.SetEnvironmentVariable(AckTokensEnabledKey, null);
Environment.SetEnvironmentVariable(WebhooksEnabledKey, null);
@@ -160,6 +175,8 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
Environment.SetEnvironmentVariable(ServiceAccountScope0Key, null);
Environment.SetEnvironmentVariable(ServiceAccountScope1Key, null);
Environment.SetEnvironmentVariable(ServiceAccountAuthorizedClientKey, null);
Environment.SetEnvironmentVariable(StorageConnectionKey, null);
Environment.SetEnvironmentVariable(StorageDatabaseKey, null);
try
{
@@ -177,4 +194,4 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
}
Task IAsyncLifetime.DisposeAsync() => DisposeAsync().AsTask();
}
}

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

@@ -1,52 +1,54 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Extensions;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Authority.Security;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public class PasswordGrantHandlersTests
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Extensions;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Cryptography.Audit;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Authority.Security;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public class PasswordGrantHandlersTests
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
[Fact]
public async Task HandlePasswordGrant_EmitsSuccessAuditEvent()
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
private readonly TestCredentialAuditContextAccessor auditContextAccessor = new();
[Fact]
public async Task HandlePasswordGrant_EmitsSuccessAuditEvent()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<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!");
@@ -56,195 +58,196 @@ public class PasswordGrantHandlersTests
var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
Assert.Equal("tenant-alpha", successEvent.Tenant.Value);
var metadata = metadataAccessor.GetMetadata();
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument();
clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true";
var clientStore = new StubClientStore(clientDocument);
var sealedValidator = new TestSealedModeEvidenceValidator
{
Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null)
};
var handler = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
sealedValidator);
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(CreatePasswordTransaction("alice", "Password1!"));
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]);
}
[Fact]
public async Task ValidateDpopProofHandler_RejectsPasswordGrant_WhenProofMissing()
{
var options = CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClientDocument();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
var clientStore = new StubClientStore(clientDocument);
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var handler = new ValidateDpopProofHandler(
options,
clientStore,
validator,
nonceStore,
metadataAccessor,
sink,
TimeProvider.System,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("DPoP proof is required.", context.ErrorDescription);
}
[Fact]
public async Task HandlePasswordGrant_AppliesDpopConfirmationClaims()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
var clientStore = new StubClientStore(clientDocument);
var options = CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var dpopValidator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var dpopHandler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
metadataAccessor,
sink,
TimeProvider.System,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
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 transaction = CreatePasswordTransaction("alice", "Password1!");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
var expectedThumbprint = TestHelpers.ConvertThumbprintToString(jwk.ComputeJwkThumbprint());
var now = TimeProvider.System.GetUtcNow();
var proof = TestHelpers.CreateDpopProof(
securityKey,
httpContext.Request.Method,
httpContext.Request.GetDisplayUrl(),
now.ToUnixTimeSeconds());
httpContext.Request.Headers["DPoP"] = proof;
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
var principal = handleContext.Principal;
Assert.NotNull(principal);
var confirmation = principal!.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmation));
using var confirmationJson = JsonDocument.Parse(confirmation!);
Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString());
Assert.Equal(AuthoritySenderConstraintKinds.Dpop, principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType));
}
var metadata = metadataAccessor.GetMetadata();
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument();
clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true";
var clientStore = new StubClientStore(clientDocument);
var sealedValidator = new TestSealedModeEvidenceValidator
{
Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null)
};
var handler = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
sealedValidator,
auditContextAccessor);
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(CreatePasswordTransaction("alice", "Password1!"));
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]);
}
[Fact]
public async Task ValidateDpopProofHandler_RejectsPasswordGrant_WhenProofMissing()
{
var options = CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var clientDocument = CreateClientDocument();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
var clientStore = new StubClientStore(clientDocument);
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var handler = new ValidateDpopProofHandler(
options,
clientStore,
validator,
nonceStore,
metadataAccessor,
sink,
TimeProvider.System,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("DPoP proof is required.", context.ErrorDescription);
}
[Fact]
public async Task HandlePasswordGrant_AppliesDpopConfirmationClaims()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
var clientStore = new StubClientStore(clientDocument);
var options = CreateAuthorityOptions(opts =>
{
opts.Security.SenderConstraints.Dpop.Enabled = true;
opts.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
});
var dpopValidator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var dpopHandler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
metadataAccessor,
sink,
TimeProvider.System,
TestActivitySource,
TestInstruments.Meter,
NullLogger<ValidateDpopProofHandler>.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!");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
var expectedThumbprint = TestHelpers.ConvertThumbprintToString(jwk.ComputeJwkThumbprint());
var now = TimeProvider.System.GetUtcNow();
var proof = TestHelpers.CreateDpopProof(
securityKey,
httpContext.Request.Method,
httpContext.Request.GetDisplayUrl(),
now.ToUnixTimeSeconds());
httpContext.Request.Headers["DPoP"] = proof;
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
var principal = handleContext.Principal;
Assert.NotNull(principal);
var confirmation = principal!.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmation));
using var confirmationJson = JsonDocument.Parse(confirmation!);
Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString());
Assert.Equal(AuthoritySenderConstraintKinds.Dpop, principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType));
}
[Fact]
public async Task HandlePasswordGrant_EmitsFailureAuditEvent()
{
@@ -252,8 +255,8 @@ public class PasswordGrantHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new FailureCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<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");
@@ -492,16 +495,16 @@ public class PasswordGrantHandlersTests
[Theory]
[InlineData("policy:publish", AuthorityOpenIddictConstants.PolicyOperationPublishValue)]
[InlineData("policy:promote", AuthorityOpenIddictConstants.PolicyOperationPromoteValue)]
public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation)
{
var sink = new TestAuthEventSink();
public async Task HandlePasswordGrant_AddsPolicyAttestationClaims(string scope, string expectedOperation)
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument(scope);
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<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");
@@ -521,65 +524,65 @@ public class PasswordGrantHandlersTests
Assert.Equal(new string('c', 64), principal.GetClaim(StellaOpsClaimTypes.PolicyDigest));
Assert.Equal("Promote approved policy", principal.GetClaim(StellaOpsClaimTypes.PolicyReason));
Assert.Equal("CR-1004", principal.GetClaim(StellaOpsClaimTypes.PolicyTicket));
Assert.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
record.Properties.Any(property => property.Name == "policy.action"));
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenPackApprovalMetadataMissing()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Pack approval tokens require pack_run_id.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PacksApprove, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task HandlePasswordGrant_AddsPackApprovalClaims()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve");
SetParameter(transaction, AuthorityOpenIddictConstants.PackRunIdParameterName, "run-123");
SetParameter(transaction, AuthorityOpenIddictConstants.PackGateIdParameterName, "security-review");
SetParameter(transaction, AuthorityOpenIddictConstants.PackPlanHashParameterName, new string(a, 64));
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
Assert.False(handleContext.IsRejected);
var principal = Assert.IsType<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.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
record.Properties.Any(property => property.Name == "pack.run_id"));
}
Assert.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
record.Properties.Any(property => property.Name == "policy.action"));
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenPackApprovalMetadataMissing()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance, auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger packs.approve");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Pack approval tokens require 'pack_run_id'.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PacksApprove, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task HandlePasswordGrant_AddsPackApprovalClaims()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("jobs:trigger packs.approve"));
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<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));
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
Assert.False(handleContext.IsRejected);
var principal = Assert.IsType<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.Contains(sink.Events, record =>
record.EventType == "authority.password.grant" &&
record.Outcome == AuthEventOutcome.Success &&
record.Properties.Any(property => property.Name == "pack.run_id"));
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant()
@@ -590,7 +593,7 @@ public class PasswordGrantHandlersTests
var clientDocument = CreateClientDocument("policy:author");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<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);
@@ -819,7 +822,7 @@ public class PasswordGrantHandlersTests
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
Credentials = store;
ClaimsEnricher = new NoopClaimsEnricher();
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false, SupportsBootstrap: false);
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false, SupportsBootstrap: false);
}
public string Name { get; }
@@ -895,15 +898,40 @@ public class PasswordGrantHandlersTests
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
private sealed class TestSealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator
{
public AuthoritySealedModeValidationResult Result { get; set; } = AuthoritySealedModeValidationResult.Success(null, null);
public ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(Result);
}
private sealed class StubClientStore : IAuthorityClientStore
private sealed class TestCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
{
private AuthorityCredentialAuditContext? current;
public AuthorityCredentialAuditContext? Current => current;
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
{
current = context;
return new Scope(() => current = null);
}
private sealed class Scope : IDisposable
{
private readonly Action onDispose;
public Scope(Action onDispose)
{
this.onDispose = onDispose;
}
public void Dispose() => onDispose();
}
}
private sealed class TestSealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator
{
public AuthoritySealedModeValidationResult Result { get; set; } = AuthoritySealedModeValidationResult.Success(null, null);
public ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(Result);
}
private sealed class StubClientStore : IAuthorityClientStore
{
private AuthorityClientDocument? document;

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));
[Fact]
public async Task HandleClientCredentials_PersistsTokenInMongo()
{
await ResetCollectionsAsync();
var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(issuedAt);
await using var provider = await BuildMongoProviderAsync(clock);
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var serviceAccountStore = provider.GetRequiredService<IAuthorityServiceAccountStore>();
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)
{
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 stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal("revoked", stored!.Status);
Assert.Equal(revokedAt, stored.RevokedAt);
Assert.Equal("manual", stored.RevokedReason);
}
[Fact]
public async Task RecordUsageAsync_FlagsSuspectedReplay_OnNewDeviceFingerprint()
{
await ResetCollectionsAsync();
var issuedAt = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(issuedAt);
await using var provider = await BuildMongoProviderAsync(clock);
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var tokenDocument = new AuthorityTokenDocument
{
TokenId = "token-replay",
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
ClientId = "client-1",
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 }
}
}
};
await tokenStore.InsertAsync(tokenDocument, CancellationToken.None);
var result = await tokenStore.RecordUsageAsync(
"token-replay",
remoteAddress: "10.0.0.2",
userAgent: "agent/2.0",
observedAt: clock.GetUtcNow(),
CancellationToken.None);
Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, result.Status);
var stored = await tokenStore.FindByTokenIdAsync("token-replay", CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(2, stored!.Devices?.Count);
Assert.Contains(stored.Devices!, doc =>
{
var remote = doc.TryGetValue("remoteAddress", out var ra) && ra.IsString ? ra.AsString : null;
var agentValue = doc.TryGetValue("userAgent", out var ua) && ua.IsString ? ua.AsString : null;
return remote == "10.0.0.2" && agentValue == "agent/2.0";
});
}
[Fact]
public async Task MongoSessions_ProvideReadYourWriteAfterPrimaryElection()
{
await ResetCollectionsAsync();
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
await using var provider = await BuildMongoProviderAsync(clock);
var tokenStore = provider.GetRequiredService<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;
}
}
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
public sealed class TokenPersistenceIntegrationTests
{
private static readonly ActivitySource Activity = new("StellaOps.Authority.Tests.Persistence");
[Fact]
public async Task PersistTokensHandler_StoresAccessTokenMetadata()
{
var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(issuedAt);
var tokenStore = new InMemoryTokenStore();
var handler = new PersistTokensHandler(tokenStore, new NullAuthorityMongoSessionAccessor(), clock, Activity, NullLogger<PersistTokensHandler>.Instance);
var identity = new ClaimsIdentity(authenticationType: "test");
identity.SetClaim(OpenIddictConstants.Claims.Subject, "subject-1");
identity.SetClaim(OpenIddictConstants.Claims.ClientId, "client-1");
identity.SetScopes("jobs:trigger");
var principal = new ClaimsPrincipal(identity);
var transaction = new OpenIddictServerTransaction
{
Request = new OpenIddictRequest(),
Options = new OpenIddictServerOptions()
};
var context = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
Principal = principal,
AccessTokenPrincipal = principal
};
await handler.HandleAsync(context);
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(OpenIddictConstants.TokenTypeHints.AccessToken, stored!.TokenType);
Assert.Equal("valid", stored.Status);
Assert.Equal(issuedAt, stored.CreatedAt);
Assert.Contains("jobs:trigger", stored.Scope);
}
[Fact]
public async Task RecordUsageAsync_FlagsReplayOnNewFingerprint()
{
var tokenStore = new InMemoryTokenStore();
var token = new AuthorityTokenDocument
{
TokenId = "token-replay",
TokenType = OpenIddictConstants.TokenTypeHints.AccessToken,
Status = "valid",
CreatedAt = DateTimeOffset.UtcNow
};
await tokenStore.InsertAsync(token, CancellationToken.None);
var first = await tokenStore.RecordUsageAsync("token-replay", "10.0.0.1", "agent/1.0", DateTimeOffset.UtcNow, CancellationToken.None);
var second = await tokenStore.RecordUsageAsync("token-replay", "10.0.0.2", "agent/2.0", DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Equal(TokenUsageUpdateStatus.Recorded, first.Status);
Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, second.Status);
}
}

View File

@@ -1,118 +1,121 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.Permalinks;
using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Auth.Abstractions;
using Xunit;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.Permalinks;
using StellaOps.Authority.Signing;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Tests.Permalinks;
#pragma warning disable CS0618 // legacy scope coverage
public sealed class VulnPermalinkServiceTests
{
[Fact]
public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims()
{
var tempDir = Directory.CreateTempSubdirectory("authority-permalink-tests").FullName;
var keyRelative = "permalink.pem";
try
{
CreateEcPrivateKey(Path.Combine(tempDir, keyRelative));
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Storage = { ConnectionString = "mongodb://localhost/test" },
Signing =
{
Enabled = true,
ActiveKeyId = "permalink-key",
KeyPath = keyRelative,
Algorithm = SignatureAlgorithms.Es256,
KeySource = "file",
Provider = "default"
}
};
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
using var provider = BuildProvider(tempDir, options, fakeTime);
// Ensure signing keys are loaded
provider.GetRequiredService<AuthoritySigningKeyManager>();
var service = provider.GetRequiredService<VulnPermalinkService>();
var state = JsonDocument.Parse("{\"vulnId\":\"CVE-2025-1234\"}").RootElement;
var request = new VulnPermalinkRequest(
Tenant: "tenant-a",
ResourceKind: "vulnerability",
State: state,
ExpiresInSeconds: null,
Environment: "prod");
var expectedNow = fakeTime.GetUtcNow();
var response = await service.CreateAsync(request, default);
Assert.NotNull(response.Token);
Assert.Equal(expectedNow, response.IssuedAt);
Assert.Equal(expectedNow.AddHours(24), response.ExpiresAt);
Assert.Contains(StellaOpsScopes.VulnRead, response.Scopes);
var parts = response.Token.Split('.');
Assert.Equal(3, parts.Length);
var payloadBytes = Base64UrlEncoder.DecodeBytes(parts[1]);
using var payloadDocument = JsonDocument.Parse(payloadBytes);
var payload = payloadDocument.RootElement;
Assert.Equal("vulnerability", payload.GetProperty("type").GetString());
Assert.Equal("tenant-a", payload.GetProperty("tenant").GetString());
Assert.Equal("prod", payload.GetProperty("environment").GetString());
Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("iat").GetInt64());
Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("nbf").GetInt64());
Assert.Equal(expectedNow.AddHours(24).ToUnixTimeSeconds(), payload.GetProperty("exp").GetInt64());
var scopes = payload.GetProperty("scopes").EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Contains(StellaOpsScopes.VulnRead, scopes);
var resource = payload.GetProperty("resource");
Assert.Equal("vulnerability", resource.GetProperty("kind").GetString());
Assert.Equal("CVE-2025-1234", resource.GetProperty("state").GetProperty("vulnId").GetString());
}
finally
{
try
{
Directory.Delete(tempDir, recursive: true);
}
catch
{
// ignore cleanup failures
}
}
}
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider)
{
{
[Fact]
public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims()
{
var tempDir = Directory.CreateTempSubdirectory("authority-permalink-tests").FullName;
var keyRelative = "permalink.pem";
try
{
CreateEcPrivateKey(Path.Combine(tempDir, keyRelative));
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test"),
Storage = { ConnectionString = "mongodb://localhost/test" },
Signing =
{
Enabled = true,
ActiveKeyId = "permalink-key",
KeyPath = keyRelative,
Algorithm = SignatureAlgorithms.Es256,
KeySource = "file",
Provider = "default"
}
};
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
using var provider = BuildProvider(tempDir, options, fakeTime);
// Ensure signing keys are loaded
provider.GetRequiredService<AuthoritySigningKeyManager>();
var service = provider.GetRequiredService<VulnPermalinkService>();
var state = JsonDocument.Parse("{\"vulnId\":\"CVE-2025-1234\"}").RootElement;
var request = new VulnPermalinkRequest(
Tenant: "tenant-a",
ResourceKind: "vulnerability",
State: state,
ExpiresInSeconds: null,
Environment: "prod");
var expectedNow = fakeTime.GetUtcNow();
var response = await service.CreateAsync(request, default);
Assert.NotNull(response.Token);
Assert.Equal(expectedNow, response.IssuedAt);
Assert.Equal(expectedNow.AddHours(24), response.ExpiresAt);
Assert.Contains(StellaOpsScopes.VulnRead, response.Scopes);
var parts = response.Token.Split('.');
Assert.Equal(3, parts.Length);
var payloadBytes = Base64UrlEncoder.DecodeBytes(parts[1]);
using var payloadDocument = JsonDocument.Parse(payloadBytes);
var payload = payloadDocument.RootElement;
Assert.Equal("vulnerability", payload.GetProperty("type").GetString());
Assert.Equal("tenant-a", payload.GetProperty("tenant").GetString());
Assert.Equal("prod", payload.GetProperty("environment").GetString());
Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("iat").GetInt64());
Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("nbf").GetInt64());
Assert.Equal(expectedNow.AddHours(24).ToUnixTimeSeconds(), payload.GetProperty("exp").GetInt64());
var scopes = payload.GetProperty("scopes").EnumerateArray().Select(element => element.GetString()).ToArray();
Assert.Contains(StellaOpsScopes.VulnRead, scopes);
var resource = payload.GetProperty("resource");
Assert.Equal("vulnerability", resource.GetProperty("kind").GetString());
Assert.Equal("CVE-2025-1234", resource.GetProperty("state").GetProperty("vulnId").GetString());
}
finally
{
try
{
Directory.Delete(tempDir, recursive: true);
}
catch
{
// ignore cleanup failures
}
}
}
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
services.AddSingleton<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);
@@ -125,32 +128,32 @@ public sealed class VulnPermalinkServiceTests
return services.BuildServiceProvider();
}
private static void CreateEcPrivateKey(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var ecdsa = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
var pem = ecdsa.ExportECPrivateKeyPem();
File.WriteAllText(path, pem);
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public TestHostEnvironment(string contentRoot)
{
ContentRootPath = contentRoot;
ContentRootFileProvider = new PhysicalFileProvider(contentRoot);
EnvironmentName = Environments.Development;
ApplicationName = "StellaOps.Authority.Tests";
}
public string EnvironmentName { get; set; }
public string ApplicationName { get; set; }
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
private static void CreateEcPrivateKey(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var ecdsa = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
var pem = ecdsa.ExportECPrivateKeyPem();
File.WriteAllText(path, pem);
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public TestHostEnvironment(string contentRoot)
{
ContentRootPath = contentRoot;
ContentRootFileProvider = new PhysicalFileProvider(contentRoot);
EnvironmentName = Environments.Development;
ApplicationName = "StellaOps.Authority.Tests";
}
public string EnvironmentName { get; set; }
public string ApplicationName { get; set; }
public string ContentRootPath { get; set; }
public IFileProvider ContentRootFileProvider { get; set; }
}
}
#pragma warning restore CS0618

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

@@ -1,91 +1,90 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<PersistTokensHandler> logger;
public PersistTokensHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<PersistTokensHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.AccessTokenPrincipal is null &&
context.RefreshTokenPrincipal is null &&
context.AuthorizationCodePrincipal is null &&
context.DeviceCodePrincipal is null)
{
return;
}
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
var issuedAt = clock.GetUtcNow();
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
{
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
{
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
{
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
{
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
}
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken)
{
var tokenId = EnsureTokenId(principal);
var scopes = ExtractScopes(principal);
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<PersistTokensHandler> logger;
public PersistTokensHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<PersistTokensHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.AccessTokenPrincipal is null &&
context.RefreshTokenPrincipal is null &&
context.AuthorizationCodePrincipal is null &&
context.DeviceCodePrincipal is null)
{
return;
}
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
var issuedAt = clock.GetUtcNow();
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
{
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
{
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
{
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
{
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
}
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken)
{
var tokenId = EnsureTokenId(principal);
var scopes = ExtractScopes(principal);
var document = new AuthorityTokenDocument
{
TokenId = tokenId,
Type = tokenType,
TokenType = tokenType,
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
Scope = scopes,
@@ -172,50 +171,50 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
document.IncidentReason = incidentReason.Trim();
}
var confirmation = principal.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
if (!string.IsNullOrWhiteSpace(confirmation))
{
try
{
using var json = JsonDocument.Parse(confirmation);
if (json.RootElement.TryGetProperty("jkt", out var thumbprintElement))
{
document.SenderKeyThumbprint = thumbprintElement.GetString();
}
else if (json.RootElement.TryGetProperty("x5t#S256", out var certificateThumbprintElement))
{
document.SenderKeyThumbprint = certificateThumbprintElement.GetString();
}
}
catch (JsonException)
{
// Ignore malformed confirmation claims in persistence layer.
}
}
try
{
await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false);
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId);
}
}
private static string EnsureTokenId(ClaimsPrincipal principal)
{
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
if (string.IsNullOrWhiteSpace(tokenId))
{
tokenId = Guid.NewGuid().ToString("N");
principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
}
return tokenId;
}
var confirmation = principal.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
if (!string.IsNullOrWhiteSpace(confirmation))
{
try
{
using var json = JsonDocument.Parse(confirmation);
if (json.RootElement.TryGetProperty("jkt", out var thumbprintElement))
{
document.SenderKeyThumbprint = thumbprintElement.GetString();
}
else if (json.RootElement.TryGetProperty("x5t#S256", out var certificateThumbprintElement))
{
document.SenderKeyThumbprint = certificateThumbprintElement.GetString();
}
}
catch (JsonException)
{
// Ignore malformed confirmation claims in persistence layer.
}
}
try
{
await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false);
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId);
}
}
private static string EnsureTokenId(ClaimsPrincipal principal)
{
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
if (string.IsNullOrWhiteSpace(tokenId))
{
tokenId = Guid.NewGuid().ToString("N");
principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
}
return tokenId;
}
private static List<string> ExtractScopes(ClaimsPrincipal principal)
=> principal.GetScopes()
.Where(scope => !string.IsNullOrWhiteSpace(scope))
@@ -265,20 +264,20 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
return null;
}
}
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
{
var value = principal.GetClaim("exp");
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
{
return DateTimeOffset.FromUnixTimeSeconds(seconds);
}
return null;
}
}
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
{
var value = principal.GetClaim("exp");
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
{
return DateTimeOffset.FromUnixTimeSeconds(seconds);
}
return null;
}
}

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)