Add authority bootstrap flows and Concelier ops runbooks
This commit is contained in:
		@@ -22,5 +22,6 @@ public static class AuthorityMongoDefaults
 | 
			
		||||
        public const string LoginAttempts = "authority_login_attempts";
 | 
			
		||||
        public const string Revocations = "authority_revocations";
 | 
			
		||||
        public const string RevocationState = "authority_revocation_state";
 | 
			
		||||
        public const string Invites = "authority_bootstrap_invites";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 bootstrap invitation token for provisioning users or clients.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[BsonIgnoreExtraElements]
 | 
			
		||||
public sealed class AuthorityBootstrapInviteDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    [BsonRepresentation(BsonType.ObjectId)]
 | 
			
		||||
    public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("token")]
 | 
			
		||||
    public string Token { get; set; } = Guid.NewGuid().ToString("N");
 | 
			
		||||
 | 
			
		||||
    [BsonElement("type")]
 | 
			
		||||
    public string Type { get; set; } = "user";
 | 
			
		||||
 | 
			
		||||
    [BsonElement("provider")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Provider { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("target")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Target { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("issuedAt")]
 | 
			
		||||
    public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("issuedBy")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? IssuedBy { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("expiresAt")]
 | 
			
		||||
    public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddDays(2);
 | 
			
		||||
 | 
			
		||||
    [BsonElement("status")]
 | 
			
		||||
    public string Status { get; set; } = AuthorityBootstrapInviteStatuses.Pending;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("reservedAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? ReservedAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("reservedBy")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ReservedBy { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("consumedAt")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? ConsumedAt { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("consumedBy")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? ConsumedBy { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("metadata")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public Dictionary<string, string?>? Metadata { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public static class AuthorityBootstrapInviteStatuses
 | 
			
		||||
{
 | 
			
		||||
    public const string Pending = "pending";
 | 
			
		||||
    public const string Reserved = "reserved";
 | 
			
		||||
    public const string Consumed = "consumed";
 | 
			
		||||
    public const string Expired = "expired";
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
@@ -61,6 +62,11 @@ public sealed class AuthorityTokenDocument
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? RevokedReasonDescription { get; set; }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    [BsonElement("devices")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public List<BsonDocument>? Devices { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("revokedMetadata")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public Dictionary<string, string?>? RevokedMetadata { get; set; }
 | 
			
		||||
 
 | 
			
		||||
@@ -98,12 +98,19 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
            return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(static sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
 | 
			
		||||
 | 
			
		||||
        services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
 | 
			
		||||
@@ -112,6 +119,7 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
        services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
 | 
			
		||||
        services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityBootstrapInviteCollectionInitializer : IAuthorityCollectionInitializer
 | 
			
		||||
{
 | 
			
		||||
    private static readonly CreateIndexModel<AuthorityBootstrapInviteDocument>[] Indexes =
 | 
			
		||||
    {
 | 
			
		||||
        new CreateIndexModel<AuthorityBootstrapInviteDocument>(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.IndexKeys.Ascending(i => i.Token),
 | 
			
		||||
            new CreateIndexOptions { Unique = true, Name = "idx_invite_token" }),
 | 
			
		||||
        new CreateIndexModel<AuthorityBootstrapInviteDocument>(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.IndexKeys.Ascending(i => i.Status).Ascending(i => i.ExpiresAt),
 | 
			
		||||
            new CreateIndexOptions { Name = "idx_invite_status_expires" })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(database);
 | 
			
		||||
 | 
			
		||||
        var collection = database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
 | 
			
		||||
        await collection.Indexes.CreateManyAsync(Indexes, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,166 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AuthorityBootstrapInviteDocument> collection;
 | 
			
		||||
 | 
			
		||||
    public AuthorityBootstrapInviteStore(IMongoCollection<AuthorityBootstrapInviteDocument> collection)
 | 
			
		||||
        => this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return document;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<BootstrapInviteReservationResult> TryReserveAsync(
 | 
			
		||||
        string token,
 | 
			
		||||
        string expectedType,
 | 
			
		||||
        DateTimeOffset now,
 | 
			
		||||
        string? reservedBy,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(token))
 | 
			
		||||
        {
 | 
			
		||||
            return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalizedToken = token.Trim();
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Pending));
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
            .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)
 | 
			
		||||
            .Set(i => i.ReservedAt, now)
 | 
			
		||||
            .Set(i => i.ReservedBy, reservedBy);
 | 
			
		||||
 | 
			
		||||
        var options = new FindOneAndUpdateOptions<AuthorityBootstrapInviteDocument>
 | 
			
		||||
        {
 | 
			
		||||
            ReturnDocument = ReturnDocument.After
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (invite is null)
 | 
			
		||||
        {
 | 
			
		||||
            var existing = await collection
 | 
			
		||||
                .Find(i => i.Token == normalizedToken)
 | 
			
		||||
                .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
                .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (existing is null)
 | 
			
		||||
            {
 | 
			
		||||
                return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (existing.Status is AuthorityBootstrapInviteStatuses.Consumed or AuthorityBootstrapInviteStatuses.Reserved)
 | 
			
		||||
            {
 | 
			
		||||
                return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.AlreadyUsed, existing);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (existing.Status == AuthorityBootstrapInviteStatuses.Expired || existing.ExpiresAt <= now)
 | 
			
		||||
            {
 | 
			
		||||
                return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, existing);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, existing);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            await ReleaseAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (invite.ExpiresAt <= now)
 | 
			
		||||
        {
 | 
			
		||||
            await MarkExpiredAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(token))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
                .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
 | 
			
		||||
                .Set(i => i.ReservedAt, null)
 | 
			
		||||
                .Set(i => i.ReservedBy, null),
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return result.ModifiedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(token))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
 | 
			
		||||
                Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
                .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
 | 
			
		||||
                .Set(i => i.ConsumedAt, consumedAt)
 | 
			
		||||
                .Set(i => i.ConsumedBy, consumedBy),
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return result.ModifiedCount > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Lte(i => i.ExpiresAt, now),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.In(
 | 
			
		||||
                i => i.Status,
 | 
			
		||||
                new[] { AuthorityBootstrapInviteStatuses.Pending, AuthorityBootstrapInviteStatuses.Reserved }));
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityBootstrapInviteDocument>.Update
 | 
			
		||||
            .Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired)
 | 
			
		||||
            .Set(i => i.ReservedAt, null)
 | 
			
		||||
            .Set(i => i.ReservedBy, null);
 | 
			
		||||
 | 
			
		||||
        var expired = await collection.Find(filter)
 | 
			
		||||
            .ToListAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (expired.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AuthorityBootstrapInviteDocument>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return expired;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token),
 | 
			
		||||
            Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired),
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,10 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
@@ -86,6 +90,86 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
 | 
			
		||||
        logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(tokenId))
 | 
			
		||||
        {
 | 
			
		||||
            return new TokenUsageUpdateResult(TokenUsageUpdateStatus.NotFound, null, null);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(remoteAddress) && string.IsNullOrWhiteSpace(userAgent))
 | 
			
		||||
        {
 | 
			
		||||
            return new TokenUsageUpdateResult(TokenUsageUpdateStatus.MissingMetadata, remoteAddress, userAgent);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var id = tokenId.Trim();
 | 
			
		||||
        var token = await collection
 | 
			
		||||
            .Find(t => t.TokenId == id)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (token is null)
 | 
			
		||||
        {
 | 
			
		||||
            return new TokenUsageUpdateResult(TokenUsageUpdateStatus.NotFound, remoteAddress, userAgent);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        token.Devices ??= new List<BsonDocument>();
 | 
			
		||||
 | 
			
		||||
        string? normalizedAddress = string.IsNullOrWhiteSpace(remoteAddress) ? null : remoteAddress.Trim();
 | 
			
		||||
        string? normalizedAgent = string.IsNullOrWhiteSpace(userAgent) ? null : userAgent.Trim();
 | 
			
		||||
 | 
			
		||||
        var device = token.Devices.FirstOrDefault(d =>
 | 
			
		||||
            string.Equals(GetString(d, "remoteAddress"), normalizedAddress, StringComparison.OrdinalIgnoreCase) &&
 | 
			
		||||
            string.Equals(GetString(d, "userAgent"), normalizedAgent, StringComparison.Ordinal));
 | 
			
		||||
        var suspicious = false;
 | 
			
		||||
 | 
			
		||||
        if (device is null)
 | 
			
		||||
        {
 | 
			
		||||
            suspicious = token.Devices.Count > 0;
 | 
			
		||||
            var document = new BsonDocument
 | 
			
		||||
            {
 | 
			
		||||
                { "remoteAddress", normalizedAddress },
 | 
			
		||||
                { "userAgent", normalizedAgent },
 | 
			
		||||
                { "firstSeen", BsonDateTime.Create(observedAt.UtcDateTime) },
 | 
			
		||||
                { "lastSeen", BsonDateTime.Create(observedAt.UtcDateTime) },
 | 
			
		||||
                { "useCount", 1 }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            token.Devices.Add(document);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            device["lastSeen"] = BsonDateTime.Create(observedAt.UtcDateTime);
 | 
			
		||||
            device["useCount"] = device.TryGetValue("useCount", out var existingCount) && existingCount.IsInt32
 | 
			
		||||
                ? existingCount.AsInt32 + 1
 | 
			
		||||
                : 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var update = Builders<AuthorityTokenDocument>.Update.Set(t => t.Devices, token.Devices);
 | 
			
		||||
        await collection.UpdateOneAsync(
 | 
			
		||||
            Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id),
 | 
			
		||||
            update,
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? GetString(BsonDocument document, string name)
 | 
			
		||||
    {
 | 
			
		||||
        if (!document.TryGetValue(name, out var value))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return value switch
 | 
			
		||||
        {
 | 
			
		||||
            { IsString: true } => value.AsString,
 | 
			
		||||
            { IsBsonNull: true } => null,
 | 
			
		||||
            _ => value.ToString()
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AuthorityTokenDocument>.Filter.And(
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,26 @@
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
 | 
			
		||||
public interface IAuthorityBootstrapInviteStore
 | 
			
		||||
{
 | 
			
		||||
    ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public enum BootstrapInviteReservationStatus
 | 
			
		||||
{
 | 
			
		||||
    Reserved,
 | 
			
		||||
    NotFound,
 | 
			
		||||
    Expired,
 | 
			
		||||
    AlreadyUsed
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public sealed record BootstrapInviteReservationResult(BootstrapInviteReservationStatus Status, AuthorityBootstrapInviteDocument? Invite);
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
@@ -21,5 +23,17 @@ public interface IAuthorityTokenStore
 | 
			
		||||
 | 
			
		||||
    ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken);
 | 
			
		||||
 | 
			
		||||
    ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public enum TokenUsageUpdateStatus
 | 
			
		||||
{
 | 
			
		||||
    Recorded,
 | 
			
		||||
    SuspectedReplay,
 | 
			
		||||
    MissingMetadata,
 | 
			
		||||
    NotFound
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public sealed record TokenUsageUpdateResult(TokenUsageUpdateStatus Status, string? RemoteAddress, string? UserAgent);
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user