Initial commit (history squashed)
This commit is contained in:
		@@ -0,0 +1,24 @@
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Constants describing default collection names and other MongoDB defaults for the Authority service.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public static class AuthorityMongoDefaults
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Default database name used when none is provided via configuration.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public const string DefaultDatabaseName = "stellaops_authority";
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Canonical collection names used by Authority.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public static class Collections
 | 
			
		||||
    {
 | 
			
		||||
        public const string Users = "authority_users";
 | 
			
		||||
        public const string Clients = "authority_clients";
 | 
			
		||||
        public const string Scopes = "authority_scopes";
 | 
			
		||||
        public const string Tokens = "authority_tokens";
 | 
			
		||||
        public const string LoginAttempts = "authority_login_attempts";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,6 @@
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo;
 | 
			
		||||
 | 
			
		||||
public class Class1
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,61 @@
 | 
			
		||||
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; }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,45 @@
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents a recorded login attempt for audit and lockout purposes.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityLoginAttemptDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SubjectId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("username")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Username { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ClientId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("plugin")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Plugin { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("successful")]
 | 
			
		||||
    public bool Successful { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("reason")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Reason { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("remoteAddress")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? RemoteAddress { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("occurredAt")]
 | 
			
		||||
    public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,38 @@
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents an OAuth scope exposed by Authority.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityScopeDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("name")]
 | 
			
		||||
    public string Name { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("displayName")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? DisplayName { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("description")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Description { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("resources")]
 | 
			
		||||
    public List<string> Resources { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("properties")]
 | 
			
		||||
    public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("updatedAt")]
 | 
			
		||||
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,54 @@
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents an OAuth token issued by Authority.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityTokenDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("tokenId")]
 | 
			
		||||
    public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
 | 
			
		||||
 | 
			
		||||
    [BsonElement("type")]
 | 
			
		||||
    public string Type { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SubjectId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ClientId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("scope")]
 | 
			
		||||
    public List<string> Scope { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("referenceId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ReferenceId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("status")]
 | 
			
		||||
    public string Status { get; set; } = "valid";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("payload")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Payload { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("expiresAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? ExpiresAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revokedAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? RevokedAt { get; set; }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents a canonical Authority user persisted in MongoDB.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityUserDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectId")]
 | 
			
		||||
    public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
 | 
			
		||||
 | 
			
		||||
    [BsonElement("username")]
 | 
			
		||||
    public string Username { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("normalizedUsername")]
 | 
			
		||||
    public string NormalizedUsername { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("displayName")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? DisplayName { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("email")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Email { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("disabled")]
 | 
			
		||||
    public bool Disabled { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("roles")]
 | 
			
		||||
    public List<string> Roles { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("attributes")]
 | 
			
		||||
    public Dictionary<string, string?> Attributes { 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;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,103 @@
 | 
			
		||||
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.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,24 @@
 | 
			
		||||
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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,26 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new[]
 | 
			
		||||
        {
 | 
			
		||||
            new CreateIndexModel<AuthorityLoginAttemptDocument>(
 | 
			
		||||
                Builders<AuthorityLoginAttemptDocument>.IndexKeys
 | 
			
		||||
                    .Ascending(a => a.SubjectId)
 | 
			
		||||
                    .Descending(a => a.OccurredAt),
 | 
			
		||||
                new CreateIndexOptions { Name = "login_attempt_subject_time" }),
 | 
			
		||||
            new CreateIndexModel<AuthorityLoginAttemptDocument>(
 | 
			
		||||
                Builders<AuthorityLoginAttemptDocument>.IndexKeys.Descending(a => a.OccurredAt),
 | 
			
		||||
                new CreateIndexOptions { Name = "login_attempt_time" })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,55 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Migrations;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Performs MongoDB bootstrap tasks for the Authority service.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class AuthorityMongoInitializer
 | 
			
		||||
{
 | 
			
		||||
    private readonly IEnumerable<IAuthorityCollectionInitializer> collectionInitializers;
 | 
			
		||||
    private readonly AuthorityMongoMigrationRunner migrationRunner;
 | 
			
		||||
    private readonly ILogger<AuthorityMongoInitializer> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityMongoInitializer(
 | 
			
		||||
        IEnumerable<IAuthorityCollectionInitializer> collectionInitializers,
 | 
			
		||||
        AuthorityMongoMigrationRunner migrationRunner,
 | 
			
		||||
        ILogger<AuthorityMongoInitializer> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collectionInitializers = collectionInitializers ?? throw new ArgumentNullException(nameof(collectionInitializers));
 | 
			
		||||
        this.migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Ensures collections exist, migrations run, and indexes are applied.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public async ValueTask InitialiseAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(database);
 | 
			
		||||
 | 
			
		||||
        await migrationRunner.RunAsync(database, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        foreach (var initializer in collectionInitializers)
 | 
			
		||||
        {
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation(
 | 
			
		||||
                    "Ensuring Authority Mongo indexes via {InitializerType}.",
 | 
			
		||||
                    initializer.GetType().FullName);
 | 
			
		||||
 | 
			
		||||
                await initializer.EnsureIndexesAsync(database, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(
 | 
			
		||||
                    ex,
 | 
			
		||||
                    "Authority Mongo index initialisation failed for {InitializerType}.",
 | 
			
		||||
                    initializer.GetType().FullName);
 | 
			
		||||
                throw;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,21 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityScopeCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new[]
 | 
			
		||||
        {
 | 
			
		||||
            new CreateIndexModel<AuthorityScopeDocument>(
 | 
			
		||||
                Builders<AuthorityScopeDocument>.IndexKeys.Ascending(s => s.Name),
 | 
			
		||||
                new CreateIndexOptions { Name = "scope_name_unique", Unique = true })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,40 @@
 | 
			
		||||
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" })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        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,27 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityUserCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var collection = database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
 | 
			
		||||
 | 
			
		||||
        var indexModels = new[]
 | 
			
		||||
        {
 | 
			
		||||
            new CreateIndexModel<AuthorityUserDocument>(
 | 
			
		||||
                Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.SubjectId),
 | 
			
		||||
                new CreateIndexOptions { Name = "user_subject_unique", Unique = true }),
 | 
			
		||||
            new CreateIndexModel<AuthorityUserDocument>(
 | 
			
		||||
                Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.NormalizedUsername),
 | 
			
		||||
                new CreateIndexOptions { Name = "user_normalized_username_unique", Unique = true, Sparse = true }),
 | 
			
		||||
            new CreateIndexModel<AuthorityUserDocument>(
 | 
			
		||||
                Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.Email),
 | 
			
		||||
                new CreateIndexOptions { Name = "user_email", Sparse = true })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Persists indexes and configuration for an Authority Mongo collection.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public interface IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Ensures the collection's indexes exist.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,40 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Executes registered Authority Mongo migrations sequentially.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class AuthorityMongoMigrationRunner
 | 
			
		||||
{
 | 
			
		||||
    private readonly IEnumerable<IAuthorityMongoMigration> migrations;
 | 
			
		||||
    private readonly ILogger<AuthorityMongoMigrationRunner> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityMongoMigrationRunner(
 | 
			
		||||
        IEnumerable<IAuthorityMongoMigration> migrations,
 | 
			
		||||
        ILogger<AuthorityMongoMigrationRunner> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.migrations = migrations ?? throw new ArgumentNullException(nameof(migrations));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask RunAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(database);
 | 
			
		||||
 | 
			
		||||
        foreach (var migration in migrations)
 | 
			
		||||
        {
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Running Authority Mongo migration {MigrationType}.", migration.GetType().FullName);
 | 
			
		||||
                await migration.ExecuteAsync(database, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(ex, "Authority Mongo migration {MigrationType} failed.", migration.GetType().FullName);
 | 
			
		||||
                throw;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,44 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Ensures base Authority collections exist prior to applying indexes.
 | 
			
		||||
/// </summary>
 | 
			
		||||
internal sealed class EnsureAuthorityCollectionsMigration : IAuthorityMongoMigration
 | 
			
		||||
{
 | 
			
		||||
    private static readonly string[] RequiredCollections =
 | 
			
		||||
    {
 | 
			
		||||
        AuthorityMongoDefaults.Collections.Users,
 | 
			
		||||
        AuthorityMongoDefaults.Collections.Clients,
 | 
			
		||||
        AuthorityMongoDefaults.Collections.Scopes,
 | 
			
		||||
        AuthorityMongoDefaults.Collections.Tokens,
 | 
			
		||||
        AuthorityMongoDefaults.Collections.LoginAttempts
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private readonly ILogger<EnsureAuthorityCollectionsMigration> logger;
 | 
			
		||||
 | 
			
		||||
    public EnsureAuthorityCollectionsMigration(ILogger<EnsureAuthorityCollectionsMigration> logger)
 | 
			
		||||
        => this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
 | 
			
		||||
    public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(database);
 | 
			
		||||
 | 
			
		||||
        var existing = await database.ListCollectionNamesAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var existingNames = await existing.ToListAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        foreach (var collection in RequiredCollections)
 | 
			
		||||
        {
 | 
			
		||||
            if (existingNames.Contains(collection, StringComparer.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            logger.LogInformation("Creating Authority Mongo collection '{CollectionName}'.", collection);
 | 
			
		||||
            await database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents a Mongo migration run during Authority bootstrap.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public interface IAuthorityMongoMigration
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Executes the migration.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="database">Mongo database instance.</param>
 | 
			
		||||
    /// <param name="cancellationToken">Cancellation token.</param>
 | 
			
		||||
    ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,64 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Options;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Strongly typed configuration for the StellaOps Authority MongoDB storage layer.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class AuthorityMongoOptions
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// MongoDB connection string used to bootstrap the client.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string ConnectionString { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Optional override for the database name. When omitted the database name embedded in the connection string is used.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string? DatabaseName { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Command timeout applied to MongoDB operations.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Returns the resolved database name.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string GetDatabaseName()
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(DatabaseName))
 | 
			
		||||
        {
 | 
			
		||||
            return DatabaseName.Trim();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(ConnectionString))
 | 
			
		||||
        {
 | 
			
		||||
            var url = MongoUrl.Create(ConnectionString);
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(url.DatabaseName))
 | 
			
		||||
            {
 | 
			
		||||
                return url.DatabaseName;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return AuthorityMongoDefaults.DefaultDatabaseName;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Validates configured values and throws when invalid.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public void EnsureValid()
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(ConnectionString))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Authority Mongo storage requires a connection string.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (CommandTimeout <= TimeSpan.Zero)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Authority Mongo storage command timeout must be greater than zero.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _ = GetDatabaseName();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +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>
 | 
			
		||||
@@ -0,0 +1,64 @@
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
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 login attempt for subject '{SubjectId}' (success={Successful}).",
 | 
			
		||||
            document.SubjectId ?? document.Username ?? "<unknown>",
 | 
			
		||||
            document.Successful);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,69 @@
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,93 @@
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityTokenDocument> collection;
 | 
			
		||||
    private readonly ILogger<AuthorityTokenStore> logger;
 | 
			
		||||
 | 
			
		||||
    public AuthorityTokenStore(
 | 
			
		||||
        IMongoCollection<AuthorityTokenDocument> collection,
 | 
			
		||||
        ILogger<AuthorityTokenStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = tokenId.Trim();
 | 
			
		||||
        return await collection.Find(t => t.TokenId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(referenceId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = referenceId.Trim();
 | 
			
		||||
        return await collection.Find(t => t.ReferenceId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Token id cannot be empty.", nameof(tokenId));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(status))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Status cannot be empty.", nameof(status));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityTokenDocument>.Update
 | 
			
		||||
            .Set(t => t.Status, status)
 | 
			
		||||
            .Set(t => t.RevokedAt, revokedAt);
 | 
			
		||||
 | 
			
		||||
        var result = await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()),
 | 
			
		||||
            update,
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        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);
 | 
			
		||||
        if (result.DeletedCount > 0)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.DeletedCount;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,81 @@
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,12 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityTokenStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user