Add tests and implement timeline ingestion options with NATS and Redis subscribers
- Introduced `BinaryReachabilityLifterTests` to validate binary lifting functionality. - Created `PackRunWorkerOptions` for configuring worker paths and execution persistence. - Added `TimelineIngestionOptions` for configuring NATS and Redis ingestion transports. - Implemented `NatsTimelineEventSubscriber` for subscribing to NATS events. - Developed `RedisTimelineEventSubscriber` for reading from Redis Streams. - Added `TimelineEnvelopeParser` to normalize incoming event envelopes. - Created unit tests for `TimelineEnvelopeParser` to ensure correct field mapping. - Implemented `TimelineAuthorizationAuditSink` for logging authorization outcomes.
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
internal static class AuthorityMongoCollectionNames
|
||||
{
|
||||
public const string ServiceAccounts = "authority_service_accounts";
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Constants describing default collection names and other MongoDB defaults for the Authority service.
|
||||
/// </summary>
|
||||
public static class AuthorityMongoDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Default database name used when none is provided via configuration.
|
||||
/// </summary>
|
||||
public const string DefaultDatabaseName = "stellaops_authority";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical collection names used by Authority.
|
||||
/// </summary>
|
||||
public static class Collections
|
||||
{
|
||||
public const string Users = "authority_users";
|
||||
public const string Clients = "authority_clients";
|
||||
public const string Scopes = "authority_scopes";
|
||||
public const string Tokens = "authority_tokens";
|
||||
public const string LoginAttempts = "authority_login_attempts";
|
||||
public const string Revocations = "authority_revocations";
|
||||
public const string RevocationState = "authority_revocation_state";
|
||||
public const string Invites = "authority_bootstrap_invites";
|
||||
public const string AirgapAudit = "authority_airgap_audit";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audit record for an air-gapped bundle import operation.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityAirgapAuditDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("tenant")]
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
[BsonElement("username")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("bundleId")]
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "unknown";
|
||||
|
||||
[BsonElement("reason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
[BsonElement("traceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
[BsonElement("occurredAt")]
|
||||
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("properties")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<AuthorityAirgapAuditPropertyDocument>? Properties { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an additional metadata entry captured for an air-gapped import audit record.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityAirgapAuditPropertyDocument
|
||||
{
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("value")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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,45 +0,0 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Captures certificate metadata associated with an mTLS-bound client.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityClientCertificateBinding
|
||||
{
|
||||
[BsonElement("thumbprint")]
|
||||
public string Thumbprint { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("serialNumber")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
[BsonElement("subject")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[BsonElement("issuer")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
[BsonElement("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; set; }
|
||||
|
||||
[BsonElement("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; set; }
|
||||
|
||||
[BsonElement("subjectAlternativeNames")]
|
||||
public List<string> SubjectAlternativeNames { get; set; } = new();
|
||||
|
||||
[BsonElement("label")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Label { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth client/application registered with Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityClientDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("clientId")]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("clientType")]
|
||||
public string ClientType { get; set; } = "confidential";
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("secretHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SecretHash { get; set; }
|
||||
|
||||
[BsonElement("permissions")]
|
||||
public List<string> Permissions { get; set; } = new();
|
||||
|
||||
[BsonElement("requirements")]
|
||||
public List<string> Requirements { get; set; } = new();
|
||||
|
||||
[BsonElement("redirectUris")]
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
|
||||
[BsonElement("postLogoutRedirectUris")]
|
||||
public List<string> PostLogoutRedirectUris { get; set; } = new();
|
||||
|
||||
[BsonElement("properties")]
|
||||
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("plugin")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Plugin { get; set; }
|
||||
|
||||
[BsonElement("senderConstraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderConstraint { get; set; }
|
||||
|
||||
[BsonElement("certificateBindings")]
|
||||
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("disabled")]
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recorded login attempt for audit and lockout purposes.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityLoginAttemptDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("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; }
|
||||
|
||||
[BsonElement("username")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("tenant")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
[BsonElement("plugin")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Plugin { get; set; }
|
||||
|
||||
[BsonElement("successful")]
|
||||
public bool Successful { get; set; }
|
||||
|
||||
[BsonElement("scopes")]
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
|
||||
[BsonElement("reason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
[BsonElement("remoteAddress")]
|
||||
[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";
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,38 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth scope exposed by Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityScopeDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("resources")]
|
||||
public List<string> Resources { get; set; } = new();
|
||||
|
||||
[BsonElement("properties")]
|
||||
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service account that can receive delegated tokens.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityServiceAccountDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("accountId")]
|
||||
public string AccountId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenant")]
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[BsonElement("allowedScopes")]
|
||||
public List<string> AllowedScopes { get; set; } = new();
|
||||
|
||||
[BsonElement("authorizedClients")]
|
||||
public List<string> AuthorizedClients { get; set; } = new();
|
||||
|
||||
[BsonElement("attributes")]
|
||||
public Dictionary<string, List<string>> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth token issued by Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityTokenDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("tokenId")]
|
||||
public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tokenKind")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TokenKind { get; set; }
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("scope")]
|
||||
public List<string> Scope { get; set; } = new();
|
||||
|
||||
[BsonElement("referenceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ReferenceId { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "valid";
|
||||
|
||||
[BsonElement("payload")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Payload { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[BsonElement("revokedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
[BsonElement("revokedReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReason { get; set; }
|
||||
|
||||
[BsonElement("revokedReasonDescription")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
|
||||
[BsonElement("senderConstraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderConstraint { get; set; }
|
||||
|
||||
[BsonElement("senderKeyThumbprint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderKeyThumbprint { get; set; }
|
||||
|
||||
[BsonElement("senderCertificateHex")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderCertificateHex { get; set; }
|
||||
|
||||
[BsonElement("senderNonce")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderNonce { get; set; }
|
||||
|
||||
[BsonElement("incidentReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? IncidentReason { get; set; }
|
||||
|
||||
[BsonElement("tenant")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
[BsonElement("project")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Project { get; set; }
|
||||
|
||||
[BsonElement("devices")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Devices { get; set; }
|
||||
|
||||
[BsonElement("revokedMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
|
||||
[BsonElement("serviceAccountId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ServiceAccountId { get; set; }
|
||||
|
||||
[BsonElement("actors")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? ActorChain { get; set; }
|
||||
|
||||
[BsonElement("vulnEnv")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityEnvironment { get; set; }
|
||||
|
||||
[BsonElement("vulnOwner")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityOwner { get; set; }
|
||||
|
||||
[BsonElement("vulnBusinessTier")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityBusinessTier { get; set; }
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a canonical Authority user persisted in MongoDB.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityUserDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("normalizedUsername")]
|
||||
public string NormalizedUsername { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("email")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[BsonElement("disabled")]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[BsonElement("roles")]
|
||||
public List<string> Roles { get; set; } = new();
|
||||
|
||||
[BsonElement("attributes")]
|
||||
public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("plugin")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Plugin { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
using StellaOps.Authority.Storage.Mongo.Options;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAuthorityMongoStorage(
|
||||
this IServiceCollection services,
|
||||
Action<AuthorityMongoOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.AddOptions<AuthorityMongoOptions>()
|
||||
.Configure(configureOptions)
|
||||
.PostConfigure(static options => options.EnsureValid());
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddSingleton<IMongoClient>(static sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
|
||||
return new MongoClient(options.ConnectionString);
|
||||
});
|
||||
|
||||
services.AddSingleton<IMongoDatabase>(static sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
|
||||
var settings = new MongoDatabaseSettings
|
||||
{
|
||||
ReadConcern = ReadConcern.Majority,
|
||||
WriteConcern = WriteConcern.WMajority,
|
||||
ReadPreference = ReadPreference.PrimaryPreferred
|
||||
};
|
||||
|
||||
var database = client.GetDatabase(options.GetDatabaseName(), settings);
|
||||
var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
|
||||
return database.WithWriteConcern(writeConcern);
|
||||
});
|
||||
|
||||
services.AddSingleton<AuthorityMongoInitializer>();
|
||||
services.AddSingleton<AuthorityMongoMigrationRunner>();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
|
||||
|
||||
services.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
|
||||
});
|
||||
|
||||
services.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.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityServiceAccountDocument>(AuthorityMongoCollectionNames.ServiceAccounts);
|
||||
});
|
||||
|
||||
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<IAuthorityCollectionInitializer, AuthorityAirgapAuditCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityServiceAccountCollectionInitializer>();
|
||||
|
||||
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>();
|
||||
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
|
||||
services.TryAddSingleton<IAuthorityAirgapAuditStore, AuthorityAirgapAuditStore>();
|
||||
services.TryAddSingleton<IAuthorityServiceAccountStore, AuthorityServiceAccountStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityAirgapAuditCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collection = database.GetCollection<AuthorityAirgapAuditDocument>(AuthorityMongoDefaults.Collections.AirgapAudit);
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityAirgapAuditDocument>(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Combine(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.Tenant),
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Descending("_id")),
|
||||
new CreateIndexOptions { Name = "airgap_audit_tenant_time" }),
|
||||
new CreateIndexModel<AuthorityAirgapAuditDocument>(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Combine(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.Tenant),
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.BundleId),
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Descending("_id")),
|
||||
new CreateIndexOptions { Name = "airgap_audit_bundle" }),
|
||||
new CreateIndexModel<AuthorityAirgapAuditDocument>(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Combine(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.Status),
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Descending("_id")),
|
||||
new CreateIndexOptions { Name = "airgap_audit_status" }),
|
||||
new CreateIndexModel<AuthorityAirgapAuditDocument>(
|
||||
Builders<AuthorityAirgapAuditDocument>.IndexKeys.Ascending(audit => audit.TraceId),
|
||||
new CreateIndexOptions { Name = "airgap_audit_trace", Sparse = true })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
|
||||
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
|
||||
new CreateIndexOptions { Name = "client_disabled" }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.SenderConstraint),
|
||||
new CreateIndexOptions { Name = "client_sender_constraint" }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending("certificateBindings.thumbprint"),
|
||||
new CreateIndexOptions { Name = "client_cert_thumbprints" })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityLoginAttemptDocument>(
|
||||
Builders<AuthorityLoginAttemptDocument>.IndexKeys
|
||||
.Ascending(a => a.SubjectId)
|
||||
.Descending(a => a.OccurredAt),
|
||||
new CreateIndexOptions { Name = "login_attempt_subject_time" }),
|
||||
new CreateIndexModel<AuthorityLoginAttemptDocument>(
|
||||
Builders<AuthorityLoginAttemptDocument>.IndexKeys.Descending(a => a.OccurredAt),
|
||||
new CreateIndexOptions { Name = "login_attempt_time" }),
|
||||
new CreateIndexModel<AuthorityLoginAttemptDocument>(
|
||||
Builders<AuthorityLoginAttemptDocument>.IndexKeys
|
||||
.Ascending(a => a.CorrelationId),
|
||||
new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true }),
|
||||
new CreateIndexModel<AuthorityLoginAttemptDocument>(
|
||||
Builders<AuthorityLoginAttemptDocument>.IndexKeys
|
||||
.Ascending(a => a.Tenant)
|
||||
.Descending(a => a.OccurredAt),
|
||||
new CreateIndexOptions { Name = "login_attempt_tenant_time", Sparse = true })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
/// <summary>
|
||||
/// Performs MongoDB bootstrap tasks for the Authority service.
|
||||
/// </summary>
|
||||
public sealed class AuthorityMongoInitializer
|
||||
{
|
||||
private readonly IEnumerable<IAuthorityCollectionInitializer> collectionInitializers;
|
||||
private readonly AuthorityMongoMigrationRunner migrationRunner;
|
||||
private readonly ILogger<AuthorityMongoInitializer> logger;
|
||||
|
||||
public AuthorityMongoInitializer(
|
||||
IEnumerable<IAuthorityCollectionInitializer> collectionInitializers,
|
||||
AuthorityMongoMigrationRunner migrationRunner,
|
||||
ILogger<AuthorityMongoInitializer> logger)
|
||||
{
|
||||
this.collectionInitializers = collectionInitializers ?? throw new ArgumentNullException(nameof(collectionInitializers));
|
||||
this.migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures collections exist, migrations run, and indexes are applied.
|
||||
/// </summary>
|
||||
public async ValueTask InitialiseAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
await migrationRunner.RunAsync(database, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var initializer in collectionInitializers)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Ensuring Authority Mongo indexes via {InitializerType}.",
|
||||
initializer.GetType().FullName);
|
||||
|
||||
await initializer.EnsureIndexesAsync(database, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Authority Mongo index initialisation failed for {InitializerType}.",
|
||||
initializer.GetType().FullName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityScopeCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityScopeDocument>(
|
||||
Builders<AuthorityScopeDocument>.IndexKeys.Ascending(s => s.Name),
|
||||
new CreateIndexOptions { Name = "scope_name_unique", Unique = true })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityServiceAccountCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collection = database.GetCollection<AuthorityServiceAccountDocument>(AuthorityMongoCollectionNames.ServiceAccounts);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityServiceAccountDocument>(
|
||||
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending(account => account.AccountId),
|
||||
new CreateIndexOptions { Name = "service_account_id_unique", Unique = true }),
|
||||
new CreateIndexModel<AuthorityServiceAccountDocument>(
|
||||
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending(account => account.Tenant).Ascending(account => account.Enabled),
|
||||
new CreateIndexOptions { Name = "service_account_tenant_enabled" }),
|
||||
new CreateIndexModel<AuthorityServiceAccountDocument>(
|
||||
Builders<AuthorityServiceAccountDocument>.IndexKeys.Ascending("authorizedClients"),
|
||||
new CreateIndexOptions { Name = "service_account_authorized_clients" })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
|
||||
var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
|
||||
{
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Status)
|
||||
.Ascending(t => t.RevokedAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderConstraint),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_constraint", Sparse = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderKeyThumbprint),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
|
||||
};
|
||||
|
||||
var serviceAccountFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ServiceAccountId, true);
|
||||
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Tenant)
|
||||
.Ascending(t => t.ServiceAccountId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Name = "token_tenant_service_account",
|
||||
PartialFilterExpression = serviceAccountFilter
|
||||
}));
|
||||
|
||||
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
|
||||
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Name = "token_expiry_ttl",
|
||||
ExpireAfter = TimeSpan.Zero,
|
||||
PartialFilterExpression = expirationFilter
|
||||
}));
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityUserCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityUserDocument>(
|
||||
Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.SubjectId),
|
||||
new CreateIndexOptions { Name = "user_subject_unique", Unique = true }),
|
||||
new CreateIndexModel<AuthorityUserDocument>(
|
||||
Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.NormalizedUsername),
|
||||
new CreateIndexOptions { Name = "user_normalized_username_unique", Unique = true, Sparse = true }),
|
||||
new CreateIndexModel<AuthorityUserDocument>(
|
||||
Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.Email),
|
||||
new CreateIndexOptions { Name = "user_email", Sparse = true })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
/// <summary>
|
||||
/// Persists indexes and configuration for an Authority Mongo collection.
|
||||
/// </summary>
|
||||
public interface IAuthorityCollectionInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures the collection's indexes exist.
|
||||
/// </summary>
|
||||
ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Executes registered Authority Mongo migrations sequentially.
|
||||
/// </summary>
|
||||
public sealed class AuthorityMongoMigrationRunner
|
||||
{
|
||||
private readonly IEnumerable<IAuthorityMongoMigration> migrations;
|
||||
private readonly ILogger<AuthorityMongoMigrationRunner> logger;
|
||||
|
||||
public AuthorityMongoMigrationRunner(
|
||||
IEnumerable<IAuthorityMongoMigration> migrations,
|
||||
ILogger<AuthorityMongoMigrationRunner> logger)
|
||||
{
|
||||
this.migrations = migrations ?? throw new ArgumentNullException(nameof(migrations));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
foreach (var migration in migrations)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Running Authority Mongo migration {MigrationType}.", migration.GetType().FullName);
|
||||
await migration.ExecuteAsync(database, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Authority Mongo migration {MigrationType} failed.", migration.GetType().FullName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures base Authority collections exist prior to applying indexes.
|
||||
/// </summary>
|
||||
internal sealed class EnsureAuthorityCollectionsMigration : IAuthorityMongoMigration
|
||||
{
|
||||
private static readonly string[] RequiredCollections =
|
||||
{
|
||||
AuthorityMongoDefaults.Collections.Users,
|
||||
AuthorityMongoDefaults.Collections.Clients,
|
||||
AuthorityMongoDefaults.Collections.Scopes,
|
||||
AuthorityMongoDefaults.Collections.Tokens,
|
||||
AuthorityMongoDefaults.Collections.LoginAttempts,
|
||||
AuthorityMongoDefaults.Collections.AirgapAudit,
|
||||
AuthorityMongoCollectionNames.ServiceAccounts
|
||||
};
|
||||
|
||||
private readonly ILogger<EnsureAuthorityCollectionsMigration> logger;
|
||||
|
||||
public EnsureAuthorityCollectionsMigration(ILogger<EnsureAuthorityCollectionsMigration> logger)
|
||||
=> this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var existing = await database.ListCollectionNamesAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var existingNames = await existing.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var collection in RequiredCollections)
|
||||
{
|
||||
if (existingNames.Contains(collection, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Creating Authority Mongo collection '{CollectionName}'.", collection);
|
||||
await database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Mongo migration run during Authority bootstrap.
|
||||
/// </summary>
|
||||
public interface IAuthorityMongoMigration
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the migration.
|
||||
/// </summary>
|
||||
/// <param name="database">Mongo database instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed configuration for the StellaOps Authority MongoDB storage layer.
|
||||
/// </summary>
|
||||
public sealed class AuthorityMongoOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// MongoDB connection string used to bootstrap the client.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for the database name. When omitted the database name embedded in the connection string is used.
|
||||
/// </summary>
|
||||
public string? DatabaseName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout applied to MongoDB operations.
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the resolved database name.
|
||||
/// </summary>
|
||||
public string GetDatabaseName()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(DatabaseName))
|
||||
{
|
||||
return DatabaseName.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
var url = MongoUrl.Create(ConnectionString);
|
||||
if (!string.IsNullOrWhiteSpace(url.DatabaseName))
|
||||
{
|
||||
return url.DatabaseName;
|
||||
}
|
||||
}
|
||||
|
||||
return AuthorityMongoDefaults.DefaultDatabaseName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates configured values and throws when invalid.
|
||||
/// </summary>
|
||||
public void EnsureValid()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Authority Mongo storage requires a connection string.");
|
||||
}
|
||||
|
||||
if (CommandTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Authority Mongo storage command timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
_ = GetDatabaseName();
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
|
||||
public interface IAuthorityMongoSessionAccessor : IAsyncDisposable
|
||||
{
|
||||
ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class AuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
{
|
||||
private readonly IMongoClient client;
|
||||
private readonly AuthorityMongoOptions options;
|
||||
private readonly object gate = new();
|
||||
private Task<IClientSessionHandle>? sessionTask;
|
||||
private IClientSessionHandle? session;
|
||||
private bool disposed;
|
||||
|
||||
public AuthorityMongoSessionAccessor(
|
||||
IMongoClient client,
|
||||
IOptions<AuthorityMongoOptions> options)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var existing = Volatile.Read(ref session);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
Task<IClientSessionHandle> startTask;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (session is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
sessionTask ??= StartSessionInternalAsync(cancellationToken);
|
||||
startTask = sessionTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var handle = await startTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
session = handle;
|
||||
sessionTask = Task.FromResult(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (ReferenceEquals(sessionTask, startTask))
|
||||
{
|
||||
sessionTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IClientSessionHandle> StartSessionInternalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionOptions = new ClientSessionOptions
|
||||
{
|
||||
CausalConsistency = true,
|
||||
DefaultTransactionOptions = new TransactionOptions(
|
||||
readPreference: ReadPreference.Primary,
|
||||
readConcern: ReadConcern.Majority,
|
||||
writeConcern: WriteConcern.WMajority.With(wTimeout: options.CommandTimeout))
|
||||
};
|
||||
|
||||
var handle = await client.StartSessionAsync(sessionOptions, cancellationToken).ConfigureAwait(false);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
IClientSessionHandle? handle;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
handle = session;
|
||||
session = null;
|
||||
sessionTask = null;
|
||||
}
|
||||
|
||||
if (handle is not null)
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,103 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityAirgapAuditStore : IAuthorityAirgapAuditStore
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
private readonly IMongoCollection<AuthorityAirgapAuditDocument> collection;
|
||||
private readonly ILogger<AuthorityAirgapAuditStore> logger;
|
||||
|
||||
public AuthorityAirgapAuditStore(
|
||||
IMongoCollection<AuthorityAirgapAuditDocument> collection,
|
||||
ILogger<AuthorityAirgapAuditStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(
|
||||
AuthorityAirgapAuditDocument document,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Recorded airgap audit entry for bundle {BundleId} under tenant {Tenant}.",
|
||||
document.BundleId,
|
||||
document.Tenant);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(
|
||||
AuthorityAirgapAuditQuery query,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query.Tenant))
|
||||
{
|
||||
return new AuthorityAirgapAuditQueryResult(Array.Empty<AuthorityAirgapAuditDocument>(), null);
|
||||
}
|
||||
|
||||
var filterBuilder = Builders<AuthorityAirgapAuditDocument>.Filter;
|
||||
var filter = filterBuilder.Eq(audit => audit.Tenant, query.Tenant.Trim());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.BundleId))
|
||||
{
|
||||
filter &= filterBuilder.Eq(audit => audit.BundleId, query.BundleId.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
filter &= filterBuilder.Eq(audit => audit.Status, query.Status.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TraceId))
|
||||
{
|
||||
filter &= filterBuilder.Eq(audit => audit.TraceId, query.TraceId.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.AfterId) && ObjectId.TryParse(query.AfterId, out var afterObjectId))
|
||||
{
|
||||
filter &= filterBuilder.Lt("_id", afterObjectId);
|
||||
}
|
||||
|
||||
var limit = query.Limit <= 0 ? DefaultLimit : Math.Min(query.Limit, MaxLimit);
|
||||
var options = new FindOptions<AuthorityAirgapAuditDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityAirgapAuditDocument>.Sort.Descending("_id"),
|
||||
Limit = limit
|
||||
};
|
||||
|
||||
IAsyncCursor<AuthorityAirgapAuditDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var nextCursor = documents.Count == limit ? documents[^1].Id : null;
|
||||
|
||||
return new AuthorityAirgapAuditQueryResult(documents, nextCursor);
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
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,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null);
|
||||
}
|
||||
|
||||
var normalizedToken = token.Trim();
|
||||
var tokenFilter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken);
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
tokenFilter,
|
||||
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
|
||||
};
|
||||
|
||||
AuthorityBootstrapInviteDocument? invite;
|
||||
if (session is { })
|
||||
{
|
||||
invite = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (invite is null)
|
||||
{
|
||||
AuthorityBootstrapInviteDocument? existing;
|
||||
if (session is { })
|
||||
{
|
||||
existing = await collection.Find(session, tokenFilter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = await collection.Find(tokenFilter)
|
||||
.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, session).ConfigureAwait(false);
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
|
||||
}
|
||||
|
||||
if (invite.ExpiresAt <= now)
|
||||
{
|
||||
await MarkExpiredAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
|
||||
}
|
||||
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
|
||||
.Set(i => i.ReservedAt, null)
|
||||
.Set(i => i.ReservedBy, null);
|
||||
|
||||
UpdateResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
|
||||
.Set(i => i.ConsumedAt, consumedAt)
|
||||
.Set(i => i.ConsumedBy, consumedBy);
|
||||
|
||||
UpdateResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
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);
|
||||
|
||||
List<AuthorityBootstrapInviteDocument> expired;
|
||||
if (session is { })
|
||||
{
|
||||
expired = await collection.Find(session, filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
expired = await collection.Find(filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return Array.Empty<AuthorityBootstrapInviteDocument>();
|
||||
}
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.UpdateManyAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session)
|
||||
{
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token);
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityClientDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityClientStore> logger;
|
||||
|
||||
public AuthorityClientStore(
|
||||
IMongoCollection<AuthorityClientDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityClientStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = clientId.Trim();
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
|
||||
var cursor = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = clientId.Trim();
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
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;
|
||||
|
||||
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
|
||||
private readonly ILogger<AuthorityLoginAttemptStore> logger;
|
||||
|
||||
public AuthorityLoginAttemptStore(
|
||||
IMongoCollection<AuthorityLoginAttemptDocument> collection,
|
||||
ILogger<AuthorityLoginAttemptStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
|
||||
document.EventType,
|
||||
document.SubjectId ?? document.Username ?? "<unknown>",
|
||||
document.Outcome);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
|
||||
{
|
||||
return Array.Empty<AuthorityLoginAttemptDocument>();
|
||||
}
|
||||
|
||||
var normalized = subjectId.Trim();
|
||||
|
||||
var filter = Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized);
|
||||
var options = new FindOptions<AuthorityLoginAttemptDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
|
||||
Limit = limit
|
||||
};
|
||||
|
||||
IAsyncCursor<AuthorityLoginAttemptDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
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
|
||||
{
|
||||
AuthorityRevocationExportStateDocument? result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
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 query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
var existing = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
document.CreatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.Id = existing.Id;
|
||||
document.CreatedAt = existing.CreatedAt;
|
||||
}
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
if (session is { })
|
||||
{
|
||||
await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.ReplaceOneAsync(filter, document, options, 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, IClientSessionHandle? session = null)
|
||||
{
|
||||
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()));
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken: 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, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
|
||||
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
var documents = await query
|
||||
.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,104 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityScopeDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityScopeStore> logger;
|
||||
|
||||
public AuthorityScopeStore(
|
||||
IMongoCollection<AuthorityScopeDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityScopeStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
IAsyncCursor<AuthorityScopeDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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 AuthorityServiceAccountStore : IAuthorityServiceAccountStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityServiceAccountDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityServiceAccountStore> logger;
|
||||
|
||||
public AuthorityServiceAccountStore(
|
||||
IMongoCollection<AuthorityServiceAccountDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityServiceAccountStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(
|
||||
string accountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = accountId.Trim();
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, normalized);
|
||||
var cursor = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(
|
||||
string tenant,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Array.Empty<AuthorityServiceAccountDocument>();
|
||||
}
|
||||
|
||||
var normalized = tenant.Trim().ToLowerInvariant();
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.Tenant, normalized);
|
||||
var cursor = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
var results = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(
|
||||
AuthorityServiceAccountDocument document,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
NormalizeDocument(document);
|
||||
var now = clock.GetUtcNow();
|
||||
document.UpdatedAt = now;
|
||||
document.CreatedAt = document.CreatedAt == default ? now : document.CreatedAt;
|
||||
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, document.AccountId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority service account {AccountId}.", document.AccountId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Updated Authority service account {AccountId}.", document.AccountId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteAsync(
|
||||
string accountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = accountId.Trim();
|
||||
var filter = Builders<AuthorityServiceAccountDocument>.Filter.Eq(account => account.AccountId, normalized);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Deleted Authority service account {AccountId}.", normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void NormalizeDocument(AuthorityServiceAccountDocument document)
|
||||
{
|
||||
document.AccountId = string.IsNullOrWhiteSpace(document.AccountId)
|
||||
? string.Empty
|
||||
: document.AccountId.Trim().ToLowerInvariant();
|
||||
|
||||
document.Tenant = string.IsNullOrWhiteSpace(document.Tenant)
|
||||
? string.Empty
|
||||
: document.Tenant.Trim().ToLowerInvariant();
|
||||
|
||||
NormalizeList(document.AllowedScopes, static scope => scope.Trim().ToLowerInvariant(), StringComparer.Ordinal);
|
||||
NormalizeList(document.AuthorizedClients, static client => client.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
NormalizeAttributes(document.Attributes);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, Func<string, string> normalizer, IEqualityComparer<string> comparer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
ArgumentNullException.ThrowIfNull(normalizer);
|
||||
comparer ??= StringComparer.Ordinal;
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(comparer);
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var current = values[index];
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = normalizer(current);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(normalized))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NormalizeAttributes(IDictionary<string, List<string>> attributes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attributes);
|
||||
|
||||
if (attributes.Count == 0)
|
||||
{
|
||||
attributes.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (name, values) in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = name.Trim().ToLowerInvariant();
|
||||
if (!AllowedAttributeKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedValues = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var wildcard = false;
|
||||
|
||||
if (values is not null)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Equals("*", StringComparison.Ordinal))
|
||||
{
|
||||
normalizedValues.Clear();
|
||||
normalizedValues.Add("*");
|
||||
wildcard = true;
|
||||
break;
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(lower))
|
||||
{
|
||||
normalizedValues.Add(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wildcard)
|
||||
{
|
||||
normalized[key] = new List<string> { "*" };
|
||||
}
|
||||
else if (normalizedValues.Count > 0)
|
||||
{
|
||||
normalized[key] = normalizedValues;
|
||||
}
|
||||
}
|
||||
|
||||
attributes.Clear();
|
||||
foreach (var pair in normalized)
|
||||
{
|
||||
attributes[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> AllowedAttributeKeys = new(new[] { "env", "owner", "business_tier" }, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
{
|
||||
private const string ServiceAccountTokenKind = "service_account";
|
||||
private readonly IMongoCollection<AuthorityTokenDocument> collection;
|
||||
private readonly ILogger<AuthorityTokenStore> logger;
|
||||
|
||||
public AuthorityTokenStore(
|
||||
IMongoCollection<AuthorityTokenDocument> collection,
|
||||
ILogger<AuthorityTokenStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogDebug("Inserted Authority token {TokenId}.", document.TokenId);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = tokenId.Trim();
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(referenceId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = referenceId.Trim();
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ReferenceId, id);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpdateStatusAsync(
|
||||
string tokenId,
|
||||
string status,
|
||||
DateTimeOffset? revokedAt,
|
||||
string? reason,
|
||||
string? reasonDescription,
|
||||
IReadOnlyDictionary<string, string?>? metadata,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
throw new ArgumentException("Token id cannot be empty.", nameof(tokenId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
throw new ArgumentException("Status cannot be empty.", nameof(status));
|
||||
}
|
||||
|
||||
var update = Builders<AuthorityTokenDocument>.Update
|
||||
.Set(t => t.Status, status)
|
||||
.Set(t => t.RevokedAt, revokedAt)
|
||||
.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 filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim());
|
||||
|
||||
UpdateResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
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 filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
var token = await query.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);
|
||||
if (session is { })
|
||||
{
|
||||
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
|
||||
}
|
||||
|
||||
public async ValueTask<long> CountActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(new[]
|
||||
{
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "valid"),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant),
|
||||
Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ServiceAccountId, true),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenKind, ServiceAccountTokenKind),
|
||||
Builders<AuthorityTokenDocument>.Filter.Or(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ExpiresAt, null),
|
||||
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.ExpiresAt, now))
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
var normalizedAccount = serviceAccountId.Trim();
|
||||
filter &= Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ServiceAccountId, normalizedAccount);
|
||||
}
|
||||
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.CountDocumentsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Array.Empty<AuthorityTokenDocument>();
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var filters = new List<FilterDefinition<AuthorityTokenDocument>>
|
||||
{
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "valid"),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenKind, ServiceAccountTokenKind),
|
||||
Builders<AuthorityTokenDocument>.Filter.Or(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ExpiresAt, null),
|
||||
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.ExpiresAt, now))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
filters.Add(Builders<AuthorityTokenDocument>.Filter.Eq(
|
||||
t => t.ServiceAccountId,
|
||||
serviceAccountId.Trim()));
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(filters);
|
||||
var options = new FindOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityTokenDocument>.Sort
|
||||
.Descending(t => t.CreatedAt)
|
||||
.Descending(t => t.TokenId)
|
||||
};
|
||||
|
||||
IAsyncCursor<AuthorityTokenDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return documents;
|
||||
}
|
||||
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(
|
||||
Builders<AuthorityTokenDocument>.Filter.Not(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked")),
|
||||
Builders<AuthorityTokenDocument>.Filter.Lt(t => t.ExpiresAt, threshold));
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteManyAsync(session, filter, options: null, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteManyAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount);
|
||||
}
|
||||
|
||||
return result.DeletedCount;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
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 query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
var documents = await query
|
||||
.Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(
|
||||
string scope,
|
||||
string tenant,
|
||||
DateTimeOffset? issuedAfter,
|
||||
int limit,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
throw new ArgumentException("Scope cannot be empty.", nameof(scope));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant cannot be empty.", nameof(tenant));
|
||||
}
|
||||
|
||||
var normalizedScope = scope.Trim();
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var effectiveLimit = limit <= 0 ? 50 : Math.Min(limit, 500);
|
||||
|
||||
var filters = new List<FilterDefinition<AuthorityTokenDocument>>
|
||||
{
|
||||
Builders<AuthorityTokenDocument>.Filter.AnyEq(t => t.Scope, normalizedScope),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant)
|
||||
};
|
||||
|
||||
if (issuedAfter is DateTimeOffset issuedThreshold)
|
||||
{
|
||||
filters.Add(Builders<AuthorityTokenDocument>.Filter.Gte(t => t.CreatedAt, issuedThreshold));
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(filters);
|
||||
var options = new FindOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityTokenDocument>.Sort.Descending(t => t.CreatedAt).Descending(t => t.TokenId),
|
||||
Limit = effectiveLimit
|
||||
};
|
||||
|
||||
IAsyncCursor<AuthorityTokenDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return documents;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityUserStore : IAuthorityUserStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityUserDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityUserStore> logger;
|
||||
|
||||
public AuthorityUserStore(
|
||||
IMongoCollection<AuthorityUserDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityUserStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = subjectId.Trim();
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalized);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedUsername))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalised = normalizedUsername.Trim();
|
||||
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.NormalizedUsername, normalised);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalised = subjectId.Trim();
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalised);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for persisting and querying air-gapped import audit records.
|
||||
/// </summary>
|
||||
public interface IAuthorityAirgapAuditStore
|
||||
{
|
||||
ValueTask InsertAsync(
|
||||
AuthorityAirgapAuditDocument document,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(
|
||||
AuthorityAirgapAuditQuery query,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query options for locating air-gapped import audit records.
|
||||
/// </summary>
|
||||
public sealed record AuthorityAirgapAuditQuery
|
||||
{
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
public string? Status { get; init; }
|
||||
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Continuation cursor (exclusive) using the Mongo document identifier.
|
||||
/// </summary>
|
||||
public string? AfterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of documents to return. Defaults to 50 and capped at 200.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result payload for air-gapped import audit queries.
|
||||
/// </summary>
|
||||
public sealed record AuthorityAirgapAuditQueryResult(
|
||||
IReadOnlyList<AuthorityAirgapAuditDocument> Items,
|
||||
string? NextCursor);
|
||||
@@ -1,27 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityBootstrapInviteStore
|
||||
{
|
||||
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
public enum BootstrapInviteReservationStatus
|
||||
{
|
||||
Reserved,
|
||||
NotFound,
|
||||
Expired,
|
||||
AlreadyUsed
|
||||
}
|
||||
|
||||
public sealed record BootstrapInviteReservationResult(BootstrapInviteReservationStatus Status, AuthorityBootstrapInviteDocument? Invite);
|
||||
@@ -1,13 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityClientStore
|
||||
{
|
||||
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityLoginAttemptStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationExportStateStore
|
||||
{
|
||||
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationStore
|
||||
{
|
||||
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityScopeStore
|
||||
{
|
||||
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityServiceAccountStore
|
||||
{
|
||||
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityTokenStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpdateStatusAsync(
|
||||
string tokenId,
|
||||
string status,
|
||||
DateTimeOffset? revokedAt,
|
||||
string? reason,
|
||||
string? reasonDescription,
|
||||
IReadOnlyDictionary<string, string?>? metadata,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(
|
||||
string scope,
|
||||
string tenant,
|
||||
DateTimeOffset? issuedAfter,
|
||||
int limit,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<long> CountActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
public enum TokenUsageUpdateStatus
|
||||
{
|
||||
Recorded,
|
||||
SuspectedReplay,
|
||||
MissingMetadata,
|
||||
NotFound
|
||||
}
|
||||
|
||||
public sealed record TokenUsageUpdateResult(TokenUsageUpdateStatus Status, string? RemoteAddress, string? UserAgent);
|
||||
@@ -1,15 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityUserStore
|
||||
{
|
||||
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
Reference in New Issue
Block a user