feat: Implement PostgreSQL repositories for various entities
- 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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -62,5 +62,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IAuthorityTokenStore, InMemoryTokenStore>();
|
||||
services.AddSingleton<IAuthorityRefreshTokenStore, InMemoryRefreshTokenStore>();
|
||||
services.AddSingleton<IAuthorityAirgapAuditStore, InMemoryAirgapAuditStore>();
|
||||
services.AddSingleton<IAuthorityRevocationExportStateStore, InMemoryRevocationExportStateStore>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)' != ''" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user