Add authority bootstrap flows and Concelier ops runbooks

This commit is contained in:
master
2025-10-15 10:03:56 +03:00
parent 0ddc014864
commit bab75fb00d
276 changed files with 21674 additions and 934 deletions

View File

@@ -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";
}
}

View File

@@ -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";
}

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);