up
This commit is contained in:
		@@ -20,5 +20,7 @@ public static class AuthorityMongoDefaults
 | 
			
		||||
        public const string Scopes = "authority_scopes";
 | 
			
		||||
        public const string Tokens = "authority_tokens";
 | 
			
		||||
        public const string LoginAttempts = "authority_login_attempts";
 | 
			
		||||
        public const string Revocations = "authority_revocations";
 | 
			
		||||
        public const string RevocationState = "authority_revocation_state";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,16 @@ public sealed class AuthorityLoginAttemptDocument
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("eventType")]
 | 
			
		||||
    public string EventType { get; set; } = "authority.unknown";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("outcome")]
 | 
			
		||||
    public string Outcome { get; set; } = "unknown";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("correlationId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? CorrelationId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SubjectId { get; set; }
 | 
			
		||||
@@ -32,6 +42,9 @@ public sealed class AuthorityLoginAttemptDocument
 | 
			
		||||
    [BsonElement("successful")]
 | 
			
		||||
    public bool Successful { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("scopes")]
 | 
			
		||||
    public List<string> Scopes { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("reason")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Reason { get; set; }
 | 
			
		||||
@@ -40,6 +53,26 @@ public sealed class AuthorityLoginAttemptDocument
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? RemoteAddress { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("properties")]
 | 
			
		||||
    public List<AuthorityLoginAttemptPropertyDocument> Properties { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("occurredAt")]
 | 
			
		||||
    public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents an additional classified property captured for an authority login attempt.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityLoginAttemptPropertyDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonElement("name")]
 | 
			
		||||
    public string Name { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("value")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Value { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("classification")]
 | 
			
		||||
    public string Classification { get; set; } = "none";
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,72 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Represents a revocation entry emitted by Authority (subject/client/token/key).
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityRevocationDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("category")]
 | 
			
		||||
    public string Category { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revocationId")]
 | 
			
		||||
    public string RevocationId { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("tokenType")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? TokenType { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? SubjectId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("clientId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ClientId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("reason")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Reason { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("reasonDescription")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ReasonDescription { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revokedAt")]
 | 
			
		||||
    public DateTimeOffset RevokedAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("effectiveAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? EffectiveAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("expiresAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? ExpiresAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("scopes")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public List<string>? Scopes { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("fingerprint")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Fingerprint { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("metadata")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public Dictionary<string, string?>? Metadata { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("updatedAt")]
 | 
			
		||||
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
using System;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityRevocationExportStateDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    public string Id { get; set; } = "state";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("sequence")]
 | 
			
		||||
    public long Sequence { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("lastBundleId")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? LastBundleId { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("lastIssuedAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? LastIssuedAt { get; set; }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
@@ -51,4 +52,16 @@ public sealed class AuthorityTokenDocument
 | 
			
		||||
    [BsonElement("revokedAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? RevokedAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revokedReason")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? RevokedReason { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revokedReasonDescription")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? RevokedReasonDescription { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revokedMetadata")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public Dictionary<string, string?>? RevokedMetadata { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -86,17 +86,32 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
            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.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<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>();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,11 @@ internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCol
 | 
			
		||||
                new CreateIndexOptions { Name = "login_attempt_subject_time" }),
 | 
			
		||||
            new CreateIndexModel<AuthorityLoginAttemptDocument>(
 | 
			
		||||
                Builders<AuthorityLoginAttemptDocument>.IndexKeys.Descending(a => a.OccurredAt),
 | 
			
		||||
                new CreateIndexOptions { Name = "login_attempt_time" })
 | 
			
		||||
                new CreateIndexOptions { Name = "login_attempt_time" }),
 | 
			
		||||
            new CreateIndexModel<AuthorityLoginAttemptDocument>(
 | 
			
		||||
                Builders<AuthorityLoginAttemptDocument>.IndexKeys
 | 
			
		||||
                    .Ascending(a => a.CorrelationId),
 | 
			
		||||
                new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityRevocationCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(database);
 | 
			
		||||
 | 
			
		||||
        var collection = database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
 | 
			
		||||
        var indexModels = new List<CreateIndexModel<AuthorityRevocationDocument>>
 | 
			
		||||
        {
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityRevocationDocument>.IndexKeys
 | 
			
		||||
                    .Ascending(d => d.Category)
 | 
			
		||||
                    .Ascending(d => d.RevocationId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_identity_unique", Unique = true }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityRevocationDocument>.IndexKeys.Ascending(d => d.RevokedAt),
 | 
			
		||||
                new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_revokedAt" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityRevocationDocument>.IndexKeys.Ascending(d => d.ExpiresAt),
 | 
			
		||||
                new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_expiresAt" })
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -22,7 +22,12 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
 | 
			
		||||
            new(
 | 
			
		||||
                Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
 | 
			
		||||
                new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" })
 | 
			
		||||
                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);
 | 
			
		||||
 
 | 
			
		||||
@@ -23,9 +23,10 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
 | 
			
		||||
 | 
			
		||||
        await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        logger.LogDebug(
 | 
			
		||||
            "Recorded login attempt for subject '{SubjectId}' (success={Successful}).",
 | 
			
		||||
            "Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
 | 
			
		||||
            document.EventType,
 | 
			
		||||
            document.SubjectId ?? document.Username ?? "<unknown>",
 | 
			
		||||
            document.Successful);
 | 
			
		||||
            document.Outcome);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,83 @@
 | 
			
		||||
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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,143 @@
 | 
			
		||||
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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
@@ -51,7 +52,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken)
 | 
			
		||||
    public async ValueTask UpdateStatusAsync(
 | 
			
		||||
        string tokenId,
 | 
			
		||||
        string status,
 | 
			
		||||
        DateTimeOffset? revokedAt,
 | 
			
		||||
        string? reason,
 | 
			
		||||
        string? reasonDescription,
 | 
			
		||||
        IReadOnlyDictionary<string, string?>? metadata,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
@@ -65,7 +73,10 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityTokenDocument>.Update
 | 
			
		||||
            .Set(t => t.Status, status)
 | 
			
		||||
            .Set(t => t.RevokedAt, revokedAt);
 | 
			
		||||
            .Set(t => t.RevokedAt, revokedAt)
 | 
			
		||||
            .Set(t => t.RevokedReason, reason)
 | 
			
		||||
            .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()),
 | 
			
		||||
@@ -90,4 +101,24 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
 | 
			
		||||
        return result.DeletedCount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked");
 | 
			
		||||
 | 
			
		||||
        if (issuedAfter is DateTimeOffset threshold)
 | 
			
		||||
        {
 | 
			
		||||
            filter = Builders<AuthorityTokenDocument>.Filter.And(
 | 
			
		||||
                filter,
 | 
			
		||||
                Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var documents = await collection
 | 
			
		||||
            .Find(filter)
 | 
			
		||||
            .Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
 | 
			
		||||
            .ToListAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return documents;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,18 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
@@ -10,7 +10,16 @@ public interface IAuthorityTokenStore
 | 
			
		||||
 | 
			
		||||
    ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken);
 | 
			
		||||
    ValueTask UpdateStatusAsync(
 | 
			
		||||
        string tokenId,
 | 
			
		||||
        string status,
 | 
			
		||||
        DateTimeOffset? revokedAt,
 | 
			
		||||
        string? reason,
 | 
			
		||||
        string? reasonDescription,
 | 
			
		||||
        IReadOnlyDictionary<string, string?>? metadata,
 | 
			
		||||
        CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user