This commit is contained in:
master
2025-10-12 20:37:18 +03:00
parent 016c5a3fe7
commit d3a98326d1
306 changed files with 21409 additions and 4449 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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