Resolve Concelier/Excititor merge conflicts
This commit is contained in:
		@@ -0,0 +1,45 @@
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Captures certificate metadata associated with an mTLS-bound client.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityClientCertificateBinding
 | 
			
		||||
{
 | 
			
		||||
    [BsonElement("thumbprint")]
 | 
			
		||||
    public string Thumbprint { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("serialNumber")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SerialNumber { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subject")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Subject { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("issuer")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Issuer { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("notBefore")]
 | 
			
		||||
    public DateTimeOffset? NotBefore { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("notAfter")]
 | 
			
		||||
    public DateTimeOffset? NotAfter { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectAlternativeNames")]
 | 
			
		||||
    public List<string> SubjectAlternativeNames { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("label")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Label { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("updatedAt")]
 | 
			
		||||
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,61 +1,69 @@
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents an OAuth client/application registered with Authority.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityClientDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientId")]
 | 
			
		||||
    public string ClientId { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientType")]
 | 
			
		||||
    public string ClientType { get; set; } = "confidential";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("displayName")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? DisplayName { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("description")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Description { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("secretHash")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SecretHash { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("permissions")]
 | 
			
		||||
    public List<string> Permissions { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("requirements")]
 | 
			
		||||
    public List<string> Requirements { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("redirectUris")]
 | 
			
		||||
    public List<string> RedirectUris { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("postLogoutRedirectUris")]
 | 
			
		||||
    public List<string> PostLogoutRedirectUris { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("properties")]
 | 
			
		||||
    public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    [BsonElement("plugin")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Plugin { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("updatedAt")]
 | 
			
		||||
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("disabled")]
 | 
			
		||||
    public bool Disabled { get; set; }
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents an OAuth client/application registered with Authority.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityClientDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientId")]
 | 
			
		||||
    public string ClientId { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientType")]
 | 
			
		||||
    public string ClientType { get; set; } = "confidential";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("displayName")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? DisplayName { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("description")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Description { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("secretHash")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SecretHash { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("permissions")]
 | 
			
		||||
    public List<string> Permissions { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("requirements")]
 | 
			
		||||
    public List<string> Requirements { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("redirectUris")]
 | 
			
		||||
    public List<string> RedirectUris { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("postLogoutRedirectUris")]
 | 
			
		||||
    public List<string> PostLogoutRedirectUris { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("properties")]
 | 
			
		||||
    public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    [BsonElement("plugin")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Plugin { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("senderConstraint")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SenderConstraint { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("certificateBindings")]
 | 
			
		||||
    public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("updatedAt")]
 | 
			
		||||
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("disabled")]
 | 
			
		||||
    public bool Disabled { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -62,6 +62,18 @@ public sealed class AuthorityTokenDocument
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? RevokedReasonDescription { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("senderConstraint")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SenderConstraint { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("senderKeyThumbprint")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SenderKeyThumbprint { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("senderNonce")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SenderNonce { get; set; }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    [BsonElement("devices")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,126 +1,129 @@
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection.Extensions;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Migrations;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Options;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Extensions;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public static class ServiceCollectionExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static IServiceCollection AddAuthorityMongoStorage(
 | 
			
		||||
        this IServiceCollection services,
 | 
			
		||||
        Action<AuthorityMongoOptions> configureOptions)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(configureOptions);
 | 
			
		||||
 | 
			
		||||
        services.AddOptions<AuthorityMongoOptions>()
 | 
			
		||||
            .Configure(configureOptions)
 | 
			
		||||
            .PostConfigure(static options => options.EnsureValid());
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton(TimeProvider.System);
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IMongoClient>(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
 | 
			
		||||
            return new MongoClient(options.ConnectionString);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IMongoDatabase>(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
 | 
			
		||||
            var client = sp.GetRequiredService<IMongoClient>();
 | 
			
		||||
 | 
			
		||||
            var settings = new MongoDatabaseSettings
 | 
			
		||||
            {
 | 
			
		||||
                ReadConcern = ReadConcern.Majority,
 | 
			
		||||
                WriteConcern = WriteConcern.WMajority,
 | 
			
		||||
                ReadPreference = ReadPreference.PrimaryPreferred
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var database = client.GetDatabase(options.GetDatabaseName(), settings);
 | 
			
		||||
            var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
 | 
			
		||||
            return database.WithWriteConcern(writeConcern);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<AuthorityMongoInitializer>();
 | 
			
		||||
        services.AddSingleton<AuthorityMongoMigrationRunner>();
 | 
			
		||||
 | 
			
		||||
        services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection.Extensions;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Migrations;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Options;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Sessions;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Extensions;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public static class ServiceCollectionExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static IServiceCollection AddAuthorityMongoStorage(
 | 
			
		||||
        this IServiceCollection services,
 | 
			
		||||
        Action<AuthorityMongoOptions> configureOptions)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(configureOptions);
 | 
			
		||||
 | 
			
		||||
        services.AddOptions<AuthorityMongoOptions>()
 | 
			
		||||
            .Configure(configureOptions)
 | 
			
		||||
            .PostConfigure(static options => options.EnsureValid());
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton(TimeProvider.System);
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IMongoClient>(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
 | 
			
		||||
            return new MongoClient(options.ConnectionString);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IMongoDatabase>(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
 | 
			
		||||
            var client = sp.GetRequiredService<IMongoClient>();
 | 
			
		||||
 | 
			
		||||
            var settings = new MongoDatabaseSettings
 | 
			
		||||
            {
 | 
			
		||||
                ReadConcern = ReadConcern.Majority,
 | 
			
		||||
                WriteConcern = WriteConcern.WMajority,
 | 
			
		||||
                ReadPreference = ReadPreference.PrimaryPreferred
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var database = client.GetDatabase(options.GetDatabaseName(), settings);
 | 
			
		||||
            var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
 | 
			
		||||
            return database.WithWriteConcern(writeConcern);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<AuthorityMongoInitializer>();
 | 
			
		||||
        services.AddSingleton<AuthorityMongoMigrationRunner>();
 | 
			
		||||
 | 
			
		||||
        services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
 | 
			
		||||
 | 
			
		||||
        services.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +1,30 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new[]
 | 
			
		||||
        {
 | 
			
		||||
            new CreateIndexModel<AuthorityClientDocument>(
 | 
			
		||||
                Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
 | 
			
		||||
                new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
 | 
			
		||||
            new CreateIndexModel<AuthorityClientDocument>(
 | 
			
		||||
                Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
 | 
			
		||||
                new CreateIndexOptions { Name = "client_disabled" })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new[]
 | 
			
		||||
        {
 | 
			
		||||
            new CreateIndexModel<AuthorityClientDocument>(
 | 
			
		||||
                Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
 | 
			
		||||
                new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
 | 
			
		||||
            new CreateIndexModel<AuthorityClientDocument>(
 | 
			
		||||
                Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
 | 
			
		||||
                new CreateIndexOptions { Name = "client_disabled" }),
 | 
			
		||||
            new CreateIndexModel<AuthorityClientDocument>(
 | 
			
		||||
                Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.SenderConstraint),
 | 
			
		||||
                new CreateIndexOptions { Name = "client_sender_constraint" }),
 | 
			
		||||
            new CreateIndexModel<AuthorityClientDocument>(
 | 
			
		||||
                Builders<AuthorityClientDocument>.IndexKeys.Ascending("certificateBindings.thumbprint"),
 | 
			
		||||
                new CreateIndexOptions { Name = "client_cert_thumbprints" })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,45 +1,51 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
 | 
			
		||||
        {
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys
 | 
			
		||||
                    .Ascending(t => t.Status)
 | 
			
		||||
                    .Ascending(t => t.RevokedAt),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
 | 
			
		||||
        indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
 | 
			
		||||
            new CreateIndexOptions<AuthorityTokenDocument>
 | 
			
		||||
            {
 | 
			
		||||
                Name = "token_expiry_ttl",
 | 
			
		||||
                ExpireAfter = TimeSpan.Zero,
 | 
			
		||||
                PartialFilterExpression = expirationFilter
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
 | 
			
		||||
        {
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys
 | 
			
		||||
                    .Ascending(t => t.Status)
 | 
			
		||||
                    .Ascending(t => t.RevokedAt),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderConstraint),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_constraint", Sparse = true }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderKeyThumbprint),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
 | 
			
		||||
        indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
 | 
			
		||||
            new CreateIndexOptions<AuthorityTokenDocument>
 | 
			
		||||
            {
 | 
			
		||||
                Name = "token_expiry_ttl",
 | 
			
		||||
                ExpireAfter = TimeSpan.Zero,
 | 
			
		||||
                PartialFilterExpression = expirationFilter
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,128 @@
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Options;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Sessions;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityMongoSessionAccessor : IAsyncDisposable
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoClient client;
 | 
			
		||||
    private readonly AuthorityMongoOptions options;
 | 
			
		||||
    private readonly object gate = new();
 | 
			
		||||
    private Task<IClientSessionHandle>? sessionTask;
 | 
			
		||||
    private IClientSessionHandle? session;
 | 
			
		||||
    private bool disposed;
 | 
			
		||||
 | 
			
		||||
    public AuthorityMongoSessionAccessor(
 | 
			
		||||
        IMongoClient client,
 | 
			
		||||
        IOptions<AuthorityMongoOptions> options)
 | 
			
		||||
    {
 | 
			
		||||
        this.client = client ?? throw new ArgumentNullException(nameof(client));
 | 
			
		||||
        this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ObjectDisposedException.ThrowIf(disposed, this);
 | 
			
		||||
 | 
			
		||||
        var existing = Volatile.Read(ref session);
 | 
			
		||||
        if (existing is not null)
 | 
			
		||||
        {
 | 
			
		||||
            return existing;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Task<IClientSessionHandle> startTask;
 | 
			
		||||
 | 
			
		||||
        lock (gate)
 | 
			
		||||
        {
 | 
			
		||||
            if (session is { } cached)
 | 
			
		||||
            {
 | 
			
		||||
                return cached;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            sessionTask ??= StartSessionInternalAsync(cancellationToken);
 | 
			
		||||
            startTask = sessionTask;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var handle = await startTask.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (session is null)
 | 
			
		||||
            {
 | 
			
		||||
                lock (gate)
 | 
			
		||||
                {
 | 
			
		||||
                    if (session is null)
 | 
			
		||||
                    {
 | 
			
		||||
                        session = handle;
 | 
			
		||||
                        sessionTask = Task.FromResult(handle);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return handle;
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            lock (gate)
 | 
			
		||||
            {
 | 
			
		||||
                if (ReferenceEquals(sessionTask, startTask))
 | 
			
		||||
                {
 | 
			
		||||
                    sessionTask = null;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            throw;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<IClientSessionHandle> StartSessionInternalAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var sessionOptions = new ClientSessionOptions
 | 
			
		||||
        {
 | 
			
		||||
            CausalConsistency = true,
 | 
			
		||||
            DefaultTransactionOptions = new TransactionOptions(
 | 
			
		||||
                readPreference: ReadPreference.Primary,
 | 
			
		||||
                readConcern: ReadConcern.Majority,
 | 
			
		||||
                writeConcern: WriteConcern.WMajority.With(wTimeout: options.CommandTimeout))
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var handle = await client.StartSessionAsync(sessionOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return handle;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public ValueTask DisposeAsync()
 | 
			
		||||
    {
 | 
			
		||||
        if (disposed)
 | 
			
		||||
        {
 | 
			
		||||
            return ValueTask.CompletedTask;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        disposed = true;
 | 
			
		||||
 | 
			
		||||
        IClientSessionHandle? handle;
 | 
			
		||||
 | 
			
		||||
        lock (gate)
 | 
			
		||||
        {
 | 
			
		||||
            handle = session;
 | 
			
		||||
            session = null;
 | 
			
		||||
            sessionTask = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (handle is not null)
 | 
			
		||||
        {
 | 
			
		||||
            handle.Dispose();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        GC.SuppressFinalize(this);
 | 
			
		||||
        return ValueTask.CompletedTask;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,18 +1,18 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <LangVersion>preview</LangVersion>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="MongoDB.Driver" Version="2.22.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <LangVersion>preview</LangVersion>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="MongoDB.Driver" Version="3.5.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
@@ -12,11 +14,19 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
 | 
			
		||||
    public AuthorityBootstrapInviteStore(IMongoCollection<AuthorityBootstrapInviteDocument> collection)
 | 
			
		||||
        => this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return document;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -25,7 +35,8 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
 | 
			
		||||
        string expectedType,
 | 
			
		||||
        DateTimeOffset now,
 | 
			
		||||
        string? reservedBy,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(token))
 | 
			
		||||
        {
 | 
			
		||||
@@ -33,8 +44,9 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalizedToken = token.Trim();
 | 
			
		||||
        var tokenFilter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken);
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken),
 | 
			
		||||
            tokenFilter,
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Pending));
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
@@ -47,14 +59,31 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
 | 
			
		||||
            ReturnDocument = ReturnDocument.After
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        AuthorityBootstrapInviteDocument? invite;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            invite = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (invite is null)
 | 
			
		||||
        {
 | 
			
		||||
            var existing = await collection
 | 
			
		||||
                .Find(i => i.Token == normalizedToken)
 | 
			
		||||
                .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
                .ConfigureAwait(false);
 | 
			
		||||
            AuthorityBootstrapInviteDocument? existing;
 | 
			
		||||
            if (session is { })
 | 
			
		||||
            {
 | 
			
		||||
                existing = await collection.Find(session, tokenFilter)
 | 
			
		||||
                    .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
                    .ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                existing = await collection.Find(tokenFilter)
 | 
			
		||||
                    .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
                    .ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (existing is null)
 | 
			
		||||
            {
 | 
			
		||||
@@ -76,60 +105,76 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
 | 
			
		||||
 | 
			
		||||
        if (!string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            await ReleaseAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await ReleaseAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
 | 
			
		||||
            return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (invite.ExpiresAt <= now)
 | 
			
		||||
        {
 | 
			
		||||
            await MarkExpiredAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await MarkExpiredAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
 | 
			
		||||
            return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(token))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
                .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
 | 
			
		||||
                .Set(i => i.ReservedAt, null)
 | 
			
		||||
                .Set(i => i.ReservedBy, null),
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
 | 
			
		||||
        var update = Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
            .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
 | 
			
		||||
            .Set(i => i.ReservedAt, null)
 | 
			
		||||
            .Set(i => i.ReservedBy, null);
 | 
			
		||||
 | 
			
		||||
        UpdateResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.ModifiedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(token))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
                .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
 | 
			
		||||
                .Set(i => i.ConsumedAt, consumedAt)
 | 
			
		||||
                .Set(i => i.ConsumedBy, consumedBy),
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
 | 
			
		||||
        var update = Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
            .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
 | 
			
		||||
            .Set(i => i.ConsumedAt, consumedAt)
 | 
			
		||||
            .Set(i => i.ConsumedBy, consumedBy);
 | 
			
		||||
 | 
			
		||||
        UpdateResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.ModifiedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Lte(i => i.ExpiresAt, now),
 | 
			
		||||
@@ -142,25 +187,49 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
 | 
			
		||||
            .Set(i => i.ReservedAt, null)
 | 
			
		||||
            .Set(i => i.ReservedBy, null);
 | 
			
		||||
 | 
			
		||||
        var expired = await collection.Find(filter)
 | 
			
		||||
            .ToListAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
        List<AuthorityBootstrapInviteDocument> expired;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            expired = await collection.Find(session, filter)
 | 
			
		||||
                .ToListAsync(cancellationToken)
 | 
			
		||||
                .ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            expired = await collection.Find(filter)
 | 
			
		||||
                .ToListAsync(cancellationToken)
 | 
			
		||||
                .ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (expired.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AuthorityBootstrapInviteDocument>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.UpdateManyAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return expired;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken)
 | 
			
		||||
    private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session)
 | 
			
		||||
    {
 | 
			
		||||
        await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired),
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token);
 | 
			
		||||
        var update = Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired);
 | 
			
		||||
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,64 +1,88 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityClientStore : IAuthorityClientStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityClientDocument> collection;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
    private readonly ILogger<AuthorityClientStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityClientStore(
 | 
			
		||||
        IMongoCollection<AuthorityClientDocument> collection,
 | 
			
		||||
        TimeProvider clock,
 | 
			
		||||
        ILogger<AuthorityClientStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(clientId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = clientId.Trim();
 | 
			
		||||
        return await collection.Find(c => c.ClientId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
 | 
			
		||||
        var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (result.UpsertedId is not null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(clientId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = clientId.Trim();
 | 
			
		||||
        var result = await collection.DeleteOneAsync(c => c.ClientId == id, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return result.DeletedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityClientStore : IAuthorityClientStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityClientDocument> collection;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
    private readonly ILogger<AuthorityClientStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityClientStore(
 | 
			
		||||
        IMongoCollection<AuthorityClientDocument> collection,
 | 
			
		||||
        TimeProvider clock,
 | 
			
		||||
        ILogger<AuthorityClientStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(clientId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = clientId.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
 | 
			
		||||
        var cursor = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
 | 
			
		||||
        ReplaceOneResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (result.UpsertedId is not null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(clientId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = clientId.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
 | 
			
		||||
 | 
			
		||||
        DeleteResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.DeletedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,52 +1,72 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityLoginAttemptStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityLoginAttemptStore(
 | 
			
		||||
        IMongoCollection<AuthorityLoginAttemptDocument> collection,
 | 
			
		||||
        ILogger<AuthorityLoginAttemptStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        logger.LogDebug(
 | 
			
		||||
            "Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
 | 
			
		||||
            document.EventType,
 | 
			
		||||
            document.SubjectId ?? document.Username ?? "<unknown>",
 | 
			
		||||
            document.Outcome);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AuthorityLoginAttemptDocument>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = subjectId.Trim();
 | 
			
		||||
 | 
			
		||||
        var cursor = await collection.FindAsync(
 | 
			
		||||
            Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized),
 | 
			
		||||
            new FindOptions<AuthorityLoginAttemptDocument>
 | 
			
		||||
            {
 | 
			
		||||
                Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
 | 
			
		||||
                Limit = limit
 | 
			
		||||
            },
 | 
			
		||||
            cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityLoginAttemptStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityLoginAttemptStore(
 | 
			
		||||
        IMongoCollection<AuthorityLoginAttemptDocument> collection,
 | 
			
		||||
        ILogger<AuthorityLoginAttemptStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logger.LogDebug(
 | 
			
		||||
            "Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
 | 
			
		||||
            document.EventType,
 | 
			
		||||
            document.SubjectId ?? document.Username ?? "<unknown>",
 | 
			
		||||
            document.Outcome);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AuthorityLoginAttemptDocument>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = subjectId.Trim();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized);
 | 
			
		||||
        var options = new FindOptions<AuthorityLoginAttemptDocument>
 | 
			
		||||
        {
 | 
			
		||||
            Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
 | 
			
		||||
            Limit = limit
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        IAsyncCursor<AuthorityLoginAttemptDocument> cursor;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,83 +1,97 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
 | 
			
		||||
{
 | 
			
		||||
    private const string StateId = "state";
 | 
			
		||||
 | 
			
		||||
    private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityRevocationExportStateStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityRevocationExportStateStore(
 | 
			
		||||
        IMongoCollection<AuthorityRevocationExportStateDocument> collection,
 | 
			
		||||
        ILogger<AuthorityRevocationExportStateStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
 | 
			
		||||
        return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
 | 
			
		||||
        long expectedSequence,
 | 
			
		||||
        long newSequence,
 | 
			
		||||
        string bundleId,
 | 
			
		||||
        DateTimeOffset issuedAt,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (newSequence <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
 | 
			
		||||
 | 
			
		||||
        if (expectedSequence > 0)
 | 
			
		||||
        {
 | 
			
		||||
            filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
 | 
			
		||||
                Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
 | 
			
		||||
                Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityRevocationExportStateDocument>.Update
 | 
			
		||||
            .Set(d => d.Sequence, newSequence)
 | 
			
		||||
            .Set(d => d.LastBundleId, bundleId)
 | 
			
		||||
            .Set(d => d.LastIssuedAt, issuedAt);
 | 
			
		||||
 | 
			
		||||
        var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
 | 
			
		||||
        {
 | 
			
		||||
            IsUpsert = expectedSequence == 0,
 | 
			
		||||
            ReturnDocument = ReturnDocument.After
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (result is null)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException("Revocation export state update conflict.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
 | 
			
		||||
            return result;
 | 
			
		||||
        }
 | 
			
		||||
        catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
 | 
			
		||||
{
 | 
			
		||||
    private const string StateId = "state";
 | 
			
		||||
 | 
			
		||||
    private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityRevocationExportStateStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityRevocationExportStateStore(
 | 
			
		||||
        IMongoCollection<AuthorityRevocationExportStateDocument> collection,
 | 
			
		||||
        ILogger<AuthorityRevocationExportStateStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
 | 
			
		||||
        long expectedSequence,
 | 
			
		||||
        long newSequence,
 | 
			
		||||
        string bundleId,
 | 
			
		||||
        DateTimeOffset issuedAt,
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (newSequence <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
 | 
			
		||||
 | 
			
		||||
        if (expectedSequence > 0)
 | 
			
		||||
        {
 | 
			
		||||
            filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
 | 
			
		||||
                Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
 | 
			
		||||
                Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityRevocationExportStateDocument>.Update
 | 
			
		||||
            .Set(d => d.Sequence, newSequence)
 | 
			
		||||
            .Set(d => d.LastBundleId, bundleId)
 | 
			
		||||
            .Set(d => d.LastIssuedAt, issuedAt);
 | 
			
		||||
 | 
			
		||||
        var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
 | 
			
		||||
        {
 | 
			
		||||
            IsUpsert = expectedSequence == 0,
 | 
			
		||||
            ReturnDocument = ReturnDocument.After
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            AuthorityRevocationExportStateDocument? result;
 | 
			
		||||
            if (session is { })
 | 
			
		||||
            {
 | 
			
		||||
                result = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (result is null)
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException("Revocation export state update conflict.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
 | 
			
		||||
            return result;
 | 
			
		||||
        }
 | 
			
		||||
        catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,143 +1,162 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityRevocationDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityRevocationStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityRevocationStore(
 | 
			
		||||
        IMongoCollection<AuthorityRevocationDocument> collection,
 | 
			
		||||
        ILogger<AuthorityRevocationStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(document.Category))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Revocation category is required.", nameof(document));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(document.RevocationId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Revocation identifier is required.", nameof(document));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        document.Category = document.Category.Trim();
 | 
			
		||||
        document.RevocationId = document.RevocationId.Trim();
 | 
			
		||||
        document.Scopes = NormalizeScopes(document.Scopes);
 | 
			
		||||
        document.Metadata = NormalizeMetadata(document.Metadata);
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityRevocationDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
 | 
			
		||||
 | 
			
		||||
        var now = DateTimeOffset.UtcNow;
 | 
			
		||||
        document.UpdatedAt = now;
 | 
			
		||||
 | 
			
		||||
        var existing = await collection
 | 
			
		||||
            .Find(filter)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (existing is null)
 | 
			
		||||
        {
 | 
			
		||||
            document.CreatedAt = now;
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            document.Id = existing.Id;
 | 
			
		||||
            document.CreatedAt = existing.CreatedAt;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityRevocationDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
 | 
			
		||||
 | 
			
		||||
        var result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (result.DeletedCount > 0)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
 | 
			
		||||
 | 
			
		||||
        var documents = await collection
 | 
			
		||||
            .Find(filter)
 | 
			
		||||
            .Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
 | 
			
		||||
            .ToListAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return documents;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static List<string>? NormalizeScopes(List<string>? scopes)
 | 
			
		||||
    {
 | 
			
		||||
        if (scopes is null || scopes.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var distinct = scopes
 | 
			
		||||
            .Where(scope => !string.IsNullOrWhiteSpace(scope))
 | 
			
		||||
            .Select(scope => scope.Trim())
 | 
			
		||||
            .Distinct(StringComparer.Ordinal)
 | 
			
		||||
            .OrderBy(scope => scope, StringComparer.Ordinal)
 | 
			
		||||
            .ToList();
 | 
			
		||||
 | 
			
		||||
        return distinct.Count == 0 ? null : distinct;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
 | 
			
		||||
    {
 | 
			
		||||
        if (metadata is null || metadata.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        foreach (var pair in metadata)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(pair.Key))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            result[pair.Key.Trim()] = pair.Value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityRevocationDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityRevocationStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityRevocationStore(
 | 
			
		||||
        IMongoCollection<AuthorityRevocationDocument> collection,
 | 
			
		||||
        ILogger<AuthorityRevocationStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(document.Category))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Revocation category is required.", nameof(document));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(document.RevocationId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Revocation identifier is required.", nameof(document));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        document.Category = document.Category.Trim();
 | 
			
		||||
        document.RevocationId = document.RevocationId.Trim();
 | 
			
		||||
        document.Scopes = NormalizeScopes(document.Scopes);
 | 
			
		||||
        document.Metadata = NormalizeMetadata(document.Metadata);
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityRevocationDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
 | 
			
		||||
 | 
			
		||||
        var now = DateTimeOffset.UtcNow;
 | 
			
		||||
        document.UpdatedAt = now;
 | 
			
		||||
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
        var existing = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (existing is null)
 | 
			
		||||
        {
 | 
			
		||||
            document.CreatedAt = now;
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            document.Id = existing.Id;
 | 
			
		||||
            document.CreatedAt = existing.CreatedAt;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityRevocationDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
 | 
			
		||||
 | 
			
		||||
        DeleteResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        if (result.DeletedCount > 0)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
 | 
			
		||||
            Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
 | 
			
		||||
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        var documents = await query
 | 
			
		||||
            .Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
 | 
			
		||||
            .ToListAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return documents;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static List<string>? NormalizeScopes(List<string>? scopes)
 | 
			
		||||
    {
 | 
			
		||||
        if (scopes is null || scopes.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var distinct = scopes
 | 
			
		||||
            .Where(scope => !string.IsNullOrWhiteSpace(scope))
 | 
			
		||||
            .Select(scope => scope.Trim())
 | 
			
		||||
            .Distinct(StringComparer.Ordinal)
 | 
			
		||||
            .OrderBy(scope => scope, StringComparer.Ordinal)
 | 
			
		||||
            .ToList();
 | 
			
		||||
 | 
			
		||||
        return distinct.Count == 0 ? null : distinct;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
 | 
			
		||||
    {
 | 
			
		||||
        if (metadata is null || metadata.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        foreach (var pair in metadata)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(pair.Key))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            result[pair.Key.Trim()] = pair.Value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,69 +1,104 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityScopeDocument> collection;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
    private readonly ILogger<AuthorityScopeStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityScopeStore(
 | 
			
		||||
        IMongoCollection<AuthorityScopeDocument> collection,
 | 
			
		||||
        TimeProvider clock,
 | 
			
		||||
        ILogger<AuthorityScopeStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(name))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = name.Trim();
 | 
			
		||||
        return await collection.Find(s => s.Name == normalized)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
 | 
			
		||||
        var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (result.UpsertedId is not null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(name))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = name.Trim();
 | 
			
		||||
        var result = await collection.DeleteOneAsync(s => s.Name == normalized, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return result.DeletedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityScopeDocument> collection;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
    private readonly ILogger<AuthorityScopeStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityScopeStore(
 | 
			
		||||
        IMongoCollection<AuthorityScopeDocument> collection,
 | 
			
		||||
        TimeProvider clock,
 | 
			
		||||
        ILogger<AuthorityScopeStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(name))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = name.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        IAsyncCursor<AuthorityScopeDocument> cursor;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            cursor = await collection.FindAsync(session, FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
 | 
			
		||||
        ReplaceOneResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (result.UpsertedId is not null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(name))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = name.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
 | 
			
		||||
 | 
			
		||||
        DeleteResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.DeletedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,12 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
@@ -22,15 +24,23 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logger.LogDebug("Inserted Authority token {TokenId}.", document.TokenId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
@@ -38,12 +48,15 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = tokenId.Trim();
 | 
			
		||||
        return await collection.Find(t => t.TokenId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(referenceId))
 | 
			
		||||
        {
 | 
			
		||||
@@ -51,9 +64,12 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = referenceId.Trim();
 | 
			
		||||
        return await collection.Find(t => t.ReferenceId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ReferenceId, id);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpdateStatusAsync(
 | 
			
		||||
@@ -63,7 +79,8 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        string? reason,
 | 
			
		||||
        string? reasonDescription,
 | 
			
		||||
        IReadOnlyDictionary<string, string?>? metadata,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
@@ -82,16 +99,29 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
            .Set(t => t.RevokedReasonDescription, reasonDescription)
 | 
			
		||||
            .Set(t => t.RevokedMetadata, metadata is null ? null : new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
        var result = await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()),
 | 
			
		||||
            update,
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim());
 | 
			
		||||
 | 
			
		||||
        UpdateResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(
 | 
			
		||||
        string tokenId,
 | 
			
		||||
        string? remoteAddress,
 | 
			
		||||
        string? userAgent,
 | 
			
		||||
        DateTimeOffset observedAt,
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
@@ -104,10 +134,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = tokenId.Trim();
 | 
			
		||||
        var token = await collection
 | 
			
		||||
            .Find(t => t.TokenId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
        var token = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (token is null)
 | 
			
		||||
        {
 | 
			
		||||
@@ -147,10 +178,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityTokenDocument>.Update.Set(t => t.Devices, token.Devices);
 | 
			
		||||
        await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id),
 | 
			
		||||
            update,
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
 | 
			
		||||
    }
 | 
			
		||||
@@ -170,14 +205,22 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.Filter.Not(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked")),
 | 
			
		||||
            Builders<AuthorityTokenDocument>.Filter.Lt(t => t.ExpiresAt, threshold));
 | 
			
		||||
 | 
			
		||||
        var result = await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        DeleteResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteManyAsync(session, filter, options: null, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteManyAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        if (result.DeletedCount > 0)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount);
 | 
			
		||||
@@ -186,7 +229,7 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        return result.DeletedCount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked");
 | 
			
		||||
 | 
			
		||||
@@ -197,8 +240,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
                Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var documents = await collection
 | 
			
		||||
            .Find(filter)
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        var documents = await query
 | 
			
		||||
            .Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
 | 
			
		||||
            .ToListAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,81 +1,105 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityUserStore : IAuthorityUserStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityUserDocument> collection;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
    private readonly ILogger<AuthorityUserStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityUserStore(
 | 
			
		||||
        IMongoCollection<AuthorityUserDocument> collection,
 | 
			
		||||
        TimeProvider clock,
 | 
			
		||||
        ILogger<AuthorityUserStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await collection
 | 
			
		||||
            .Find(u => u.SubjectId == subjectId)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(normalizedUsername))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalised = normalizedUsername.Trim();
 | 
			
		||||
 | 
			
		||||
        return await collection
 | 
			
		||||
            .Find(u => u.NormalizedUsername == normalised)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
 | 
			
		||||
        var result = await collection
 | 
			
		||||
            .ReplaceOneAsync(filter, document, options, cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (result.UpsertedId is not null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalised = subjectId.Trim();
 | 
			
		||||
        var result = await collection.DeleteOneAsync(u => u.SubjectId == normalised, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return result.DeletedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityUserStore : IAuthorityUserStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityUserDocument> collection;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
    private readonly ILogger<AuthorityUserStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityUserStore(
 | 
			
		||||
        IMongoCollection<AuthorityUserDocument> collection,
 | 
			
		||||
        TimeProvider clock,
 | 
			
		||||
        ILogger<AuthorityUserStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = subjectId.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalized);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(normalizedUsername))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalised = normalizedUsername.Trim();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.NormalizedUsername, normalised);
 | 
			
		||||
        var query = session is { }
 | 
			
		||||
            ? collection.Find(session, filter)
 | 
			
		||||
            : collection.Find(filter);
 | 
			
		||||
 | 
			
		||||
        return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
 | 
			
		||||
        var options = new ReplaceOptions { IsUpsert = true };
 | 
			
		||||
 | 
			
		||||
        ReplaceOneResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (result.UpsertedId is not null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalised = subjectId.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalised);
 | 
			
		||||
 | 
			
		||||
        DeleteResult result;
 | 
			
		||||
        if (session is { })
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.DeletedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,19 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityBootstrapInviteStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public enum BootstrapInviteReservationStatus
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,13 @@
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityClientStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityClientStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,11 @@
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityLoginAttemptStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityLoginAttemptStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,20 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityRevocationExportStateStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
 | 
			
		||||
        long expectedSequence,
 | 
			
		||||
        long newSequence,
 | 
			
		||||
        string bundleId,
 | 
			
		||||
        DateTimeOffset issuedAt,
 | 
			
		||||
        CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityRevocationExportStateStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
 | 
			
		||||
        long expectedSequence,
 | 
			
		||||
        long newSequence,
 | 
			
		||||
        string bundleId,
 | 
			
		||||
        DateTimeOffset issuedAt,
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,17 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityRevocationStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityRevocationStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,15 @@
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityScopeStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityScopeStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,17 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityTokenStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpdateStatusAsync(
 | 
			
		||||
        string tokenId,
 | 
			
		||||
@@ -19,13 +20,14 @@ public interface IAuthorityTokenStore
 | 
			
		||||
        string? reason,
 | 
			
		||||
        string? reasonDescription,
 | 
			
		||||
        IReadOnlyDictionary<string, string?>? metadata,
 | 
			
		||||
        CancellationToken cancellationToken);
 | 
			
		||||
        CancellationToken cancellationToken,
 | 
			
		||||
        IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public enum TokenUsageUpdateStatus
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,15 @@
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityUserStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityUserStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user