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);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Canonical persistence for raw documents, DTOs, canonical advisories, jobs, and state. Provides repositories and bootstrapper for collections/indexes.
|
||||
## Scope
|
||||
- Collections (MongoStorageDefaults): source, source_state, document, dto, advisory, alias, affected, reference, kev_flag, ru_flags, jp_flags, psirt_flags, merge_event, export_state, locks, jobs; GridFS bucket fs.documents; field names include ttlAt (locks), sourceName, uri, advisoryKey.
|
||||
- Records: SourceState (cursor, lastSuccess/error, failCount, backoffUntil), JobRun, MergeEvent, ExportState, Advisory documents mirroring Models with embedded arrays when practical.
|
||||
- Bootstrapper: create collections, indexes (unique advisoryKey, scheme/value, platform/name, published, modified), TTL on locks, and validate connectivity for /ready health probes.
|
||||
- Job store: create, read, mark completed/failed; compute durations; recent/last queries; active by status.
|
||||
- Advisory store: CRUD for canonical advisories; query by key/alias and list for exporters with deterministic paging.
|
||||
## Participants
|
||||
- Core jobs read/write runs and leases; WebService /ready pings database; /jobs APIs query runs/definitions.
|
||||
- Source connectors store raw docs, DTOs, and mapped canonical advisories with provenance; Update SourceState cursor/backoff.
|
||||
- Exporters read advisories and write export_state.
|
||||
## Interfaces & contracts
|
||||
- IMongoDatabase injected; MongoUrl from options; database name from options or MongoUrl or default "concelier".
|
||||
- Repositories expose async methods with CancellationToken; deterministic sorting.
|
||||
- All date/time values stored as UTC; identifiers normalized.
|
||||
## In/Out of scope
|
||||
In: persistence, bootstrap, indexes, basic query helpers.
|
||||
Out: business mapping logic, HTTP, packaging.
|
||||
## Observability & security expectations
|
||||
- Log collection/index creation; warn on existing mismatches.
|
||||
- Timeouts and retry policies; avoid unbounded scans; page reads.
|
||||
- Do not log DSNs with credentials; redact in diagnostics.
|
||||
## Tests
|
||||
- Author and review coverage in `../StellaOps.Concelier.Storage.Mongo.Tests`.
|
||||
- Shared fixtures (e.g., `MongoIntegrationFixture`, `ConnectorTestHarness`) live in `../StellaOps.Concelier.Testing`.
|
||||
- Keep fixtures deterministic; match new cases to real-world advisories or regression scenarios.
|
||||
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/concelier/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AdvisoryDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("advisoryKey")]
|
||||
public string AdvisoryKey
|
||||
{
|
||||
get => Id;
|
||||
set => Id = value;
|
||||
}
|
||||
|
||||
[BsonElement("payload")]
|
||||
public BsonDocument Payload { get; set; } = new();
|
||||
|
||||
[BsonElement("modified")]
|
||||
public DateTime Modified { get; set; }
|
||||
|
||||
[BsonElement("published")]
|
||||
public DateTime? Published { get; set; }
|
||||
|
||||
[BsonElement("normalizedVersions")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<NormalizedVersionDocument>? NormalizedVersions { get; set; }
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
|
||||
public sealed class AdvisoryStore : IAdvisoryStore
|
||||
{
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly IMongoCollection<AdvisoryDocument> _collection;
|
||||
private readonly ILogger<AdvisoryStore> _logger;
|
||||
private readonly IAliasStore _aliasStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly MongoStorageOptions _options;
|
||||
private IMongoCollection<AdvisoryDocument>? _legacyCollection;
|
||||
|
||||
public AdvisoryStore(
|
||||
IMongoDatabase database,
|
||||
IAliasStore aliasStore,
|
||||
ILogger<AdvisoryStore> logger,
|
||||
IOptions<MongoStorageOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_database = database ?? throw new ArgumentNullException(nameof(database));
|
||||
_collection = _database.GetCollection<AdvisoryDocument>(MongoStorageDefaults.Collections.Advisory);
|
||||
_aliasStore = aliasStore ?? throw new ArgumentNullException(nameof(aliasStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
|
||||
public async Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisory);
|
||||
|
||||
var missing = ProvenanceInspector.FindMissingProvenance(advisory);
|
||||
var primarySource = advisory.Provenance.FirstOrDefault()?.Source ?? "unknown";
|
||||
foreach (var item in missing)
|
||||
{
|
||||
var source = string.IsNullOrWhiteSpace(item.Source) ? primarySource : item.Source;
|
||||
_logger.LogWarning(
|
||||
"Missing provenance detected for {Component} in advisory {AdvisoryKey} (source {Source}).",
|
||||
item.Component,
|
||||
advisory.AdvisoryKey,
|
||||
source);
|
||||
ProvenanceDiagnostics.RecordMissing(source, item.Component, item.RecordedAt, item.FieldMask);
|
||||
}
|
||||
|
||||
var payload = CanonicalJsonSerializer.Serialize(advisory);
|
||||
var normalizedVersions = _options.EnableSemVerStyle
|
||||
? NormalizedVersionDocumentFactory.Create(advisory)
|
||||
: null;
|
||||
var document = new AdvisoryDocument
|
||||
{
|
||||
AdvisoryKey = advisory.AdvisoryKey,
|
||||
Payload = BsonDocument.Parse(payload),
|
||||
Modified = advisory.Modified?.UtcDateTime ?? DateTime.UtcNow,
|
||||
Published = advisory.Published?.UtcDateTime,
|
||||
NormalizedVersions = normalizedVersions,
|
||||
};
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
var filter = Builders<AdvisoryDocument>.Filter.Eq(x => x.AdvisoryKey, advisory.AdvisoryKey);
|
||||
await ReplaceAsync(filter, document, options, session, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Upserted advisory {AdvisoryKey}", advisory.AdvisoryKey);
|
||||
|
||||
var aliasEntries = BuildAliasEntries(advisory);
|
||||
var updatedAt = _timeProvider.GetUtcNow();
|
||||
await _aliasStore.ReplaceAsync(advisory.AdvisoryKey, aliasEntries, updatedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
var filter = Builders<AdvisoryDocument>.Filter.Eq(x => x.AdvisoryKey, advisoryKey);
|
||||
var query = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
|
||||
var document = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return document is null ? null : Deserialize(document.Payload);
|
||||
}
|
||||
|
||||
private static IEnumerable<AliasEntry> BuildAliasEntries(Advisory advisory)
|
||||
{
|
||||
foreach (var alias in advisory.Aliases)
|
||||
{
|
||||
if (AliasSchemeRegistry.TryGetScheme(alias, out var scheme))
|
||||
{
|
||||
yield return new AliasEntry(scheme, alias);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new AliasEntry(AliasStoreConstants.UnscopedScheme, alias);
|
||||
}
|
||||
}
|
||||
|
||||
yield return new AliasEntry(AliasStoreConstants.PrimaryScheme, advisory.AdvisoryKey);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = FilterDefinition<AdvisoryDocument>.Empty;
|
||||
var query = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
var cursor = await query
|
||||
.SortByDescending(x => x.Modified)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return cursor.Select(static doc => Deserialize(doc.Payload)).ToArray();
|
||||
}
|
||||
|
||||
private async Task ReplaceAsync(
|
||||
FilterDefinition<AdvisoryDocument> filter,
|
||||
AdvisoryDocument document,
|
||||
ReplaceOptions options,
|
||||
IClientSessionHandle? session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
await _collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (MongoWriteException ex) when (IsNamespaceViewError(ex))
|
||||
{
|
||||
var legacyCollection = await GetLegacyAdvisoryCollectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (session is null)
|
||||
{
|
||||
await legacyCollection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await legacyCollection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNamespaceViewError(MongoWriteException ex)
|
||||
=> ex?.WriteError?.Code == 166 ||
|
||||
(ex?.WriteError?.Message?.Contains("is a view", StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
private async ValueTask<IMongoCollection<AdvisoryDocument>> GetLegacyAdvisoryCollectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_legacyCollection is not null)
|
||||
{
|
||||
return _legacyCollection;
|
||||
}
|
||||
|
||||
var filter = new BsonDocument("name", MongoStorageDefaults.Collections.Advisory);
|
||||
using var cursor = await _database
|
||||
.ListCollectionsAsync(new ListCollectionsOptions { Filter = filter }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var info = await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Advisory collection metadata not found.");
|
||||
|
||||
if (!info.TryGetValue("options", out var optionsValue) || optionsValue is not BsonDocument optionsDocument)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory view options missing.");
|
||||
}
|
||||
|
||||
if (!optionsDocument.TryGetValue("viewOn", out var viewOnValue) || viewOnValue.BsonType != BsonType.String)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory view target not specified.");
|
||||
}
|
||||
|
||||
var targetName = viewOnValue.AsString;
|
||||
_legacyCollection = _database.GetCollection<AdvisoryDocument>(targetName);
|
||||
return _legacyCollection;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Advisory> StreamAsync([EnumeratorCancellation] CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var options = new FindOptions<AdvisoryDocument>
|
||||
{
|
||||
Sort = Builders<AdvisoryDocument>.Sort.Ascending(static doc => doc.AdvisoryKey),
|
||||
};
|
||||
|
||||
using var cursor = session is null
|
||||
? await _collection.FindAsync(FilterDefinition<AdvisoryDocument>.Empty, options, cancellationToken).ConfigureAwait(false)
|
||||
: await _collection.FindAsync(session, FilterDefinition<AdvisoryDocument>.Empty, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
foreach (var document in cursor.Current)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return Deserialize(document.Payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory Deserialize(BsonDocument payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var advisoryKey = payload.GetValue("advisoryKey", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("advisoryKey missing from payload.");
|
||||
var title = payload.GetValue("title", defaultValue: null)?.AsString ?? advisoryKey;
|
||||
|
||||
string? summary = payload.TryGetValue("summary", out var summaryValue) && summaryValue.IsString ? summaryValue.AsString : null;
|
||||
string? description = payload.TryGetValue("description", out var descriptionValue) && descriptionValue.IsString ? descriptionValue.AsString : null;
|
||||
string? language = payload.TryGetValue("language", out var languageValue) && languageValue.IsString ? languageValue.AsString : null;
|
||||
DateTimeOffset? published = TryReadDateTime(payload, "published");
|
||||
DateTimeOffset? modified = TryReadDateTime(payload, "modified");
|
||||
string? severity = payload.TryGetValue("severity", out var severityValue) && severityValue.IsString ? severityValue.AsString : null;
|
||||
var exploitKnown = payload.TryGetValue("exploitKnown", out var exploitValue) && exploitValue.IsBoolean && exploitValue.AsBoolean;
|
||||
|
||||
var aliases = payload.TryGetValue("aliases", out var aliasValue) && aliasValue is BsonArray aliasArray
|
||||
? aliasArray.OfType<BsonValue>().Where(static x => x.IsString).Select(static x => x.AsString)
|
||||
: Array.Empty<string>();
|
||||
|
||||
var credits = payload.TryGetValue("credits", out var creditsValue) && creditsValue is BsonArray creditsArray
|
||||
? creditsArray.OfType<BsonDocument>().Select(DeserializeCredit).ToArray()
|
||||
: Array.Empty<AdvisoryCredit>();
|
||||
|
||||
var references = payload.TryGetValue("references", out var referencesValue) && referencesValue is BsonArray referencesArray
|
||||
? referencesArray.OfType<BsonDocument>().Select(DeserializeReference).ToArray()
|
||||
: Array.Empty<AdvisoryReference>();
|
||||
|
||||
var affectedPackages = payload.TryGetValue("affectedPackages", out var affectedValue) && affectedValue is BsonArray affectedArray
|
||||
? affectedArray.OfType<BsonDocument>().Select(DeserializeAffectedPackage).ToArray()
|
||||
: Array.Empty<AffectedPackage>();
|
||||
|
||||
var cvssMetrics = payload.TryGetValue("cvssMetrics", out var cvssValue) && cvssValue is BsonArray cvssArray
|
||||
? cvssArray.OfType<BsonDocument>().Select(DeserializeCvssMetric).ToArray()
|
||||
: Array.Empty<CvssMetric>();
|
||||
|
||||
var cwes = payload.TryGetValue("cwes", out var cweValue) && cweValue is BsonArray cweArray
|
||||
? cweArray.OfType<BsonDocument>().Select(DeserializeWeakness).ToArray()
|
||||
: Array.Empty<AdvisoryWeakness>();
|
||||
|
||||
string? canonicalMetricId = payload.TryGetValue("canonicalMetricId", out var canonicalMetricValue) && canonicalMetricValue.IsString
|
||||
? canonicalMetricValue.AsString
|
||||
: null;
|
||||
|
||||
var provenance = payload.TryGetValue("provenance", out var provenanceValue) && provenanceValue is BsonArray provenanceArray
|
||||
? provenanceArray.OfType<BsonDocument>().Select(DeserializeProvenance).ToArray()
|
||||
: Array.Empty<AdvisoryProvenance>();
|
||||
|
||||
return new Advisory(
|
||||
advisoryKey,
|
||||
title,
|
||||
summary,
|
||||
language,
|
||||
published,
|
||||
modified,
|
||||
severity,
|
||||
exploitKnown,
|
||||
aliases,
|
||||
credits,
|
||||
references,
|
||||
affectedPackages,
|
||||
cvssMetrics,
|
||||
provenance,
|
||||
description,
|
||||
cwes,
|
||||
canonicalMetricId);
|
||||
}
|
||||
|
||||
private static AdvisoryReference DeserializeReference(BsonDocument document)
|
||||
{
|
||||
var url = document.GetValue("url", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("reference.url missing from payload.");
|
||||
string? kind = document.TryGetValue("kind", out var kindValue) && kindValue.IsString ? kindValue.AsString : null;
|
||||
string? sourceTag = document.TryGetValue("sourceTag", out var sourceTagValue) && sourceTagValue.IsString ? sourceTagValue.AsString : null;
|
||||
string? summary = document.TryGetValue("summary", out var summaryValue) && summaryValue.IsString ? summaryValue.AsString : null;
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument
|
||||
? DeserializeProvenance(provenanceValue.AsBsonDocument)
|
||||
: AdvisoryProvenance.Empty;
|
||||
|
||||
return new AdvisoryReference(url, kind, sourceTag, summary, provenance);
|
||||
}
|
||||
|
||||
private static AdvisoryCredit DeserializeCredit(BsonDocument document)
|
||||
{
|
||||
var displayName = document.GetValue("displayName", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("credits.displayName missing from payload.");
|
||||
string? role = document.TryGetValue("role", out var roleValue) && roleValue.IsString ? roleValue.AsString : null;
|
||||
var contacts = document.TryGetValue("contacts", out var contactsValue) && contactsValue is BsonArray contactsArray
|
||||
? contactsArray.OfType<BsonValue>().Where(static value => value.IsString).Select(static value => value.AsString).ToArray()
|
||||
: Array.Empty<string>();
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument
|
||||
? DeserializeProvenance(provenanceValue.AsBsonDocument)
|
||||
: AdvisoryProvenance.Empty;
|
||||
|
||||
return new AdvisoryCredit(displayName, role, contacts, provenance);
|
||||
}
|
||||
|
||||
private static AffectedPackage DeserializeAffectedPackage(BsonDocument document)
|
||||
{
|
||||
var type = document.GetValue("type", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("affectedPackages.type missing from payload.");
|
||||
var identifier = document.GetValue("identifier", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("affectedPackages.identifier missing from payload.");
|
||||
string? platform = document.TryGetValue("platform", out var platformValue) && platformValue.IsString ? platformValue.AsString : null;
|
||||
|
||||
var versionRanges = document.TryGetValue("versionRanges", out var rangesValue) && rangesValue is BsonArray rangesArray
|
||||
? rangesArray.OfType<BsonDocument>().Select(DeserializeVersionRange).ToArray()
|
||||
: Array.Empty<AffectedVersionRange>();
|
||||
|
||||
var statuses = document.TryGetValue("statuses", out var statusesValue) && statusesValue is BsonArray statusesArray
|
||||
? statusesArray.OfType<BsonDocument>().Select(DeserializeStatus).ToArray()
|
||||
: Array.Empty<AffectedPackageStatus>();
|
||||
|
||||
var normalizedVersions = document.TryGetValue("normalizedVersions", out var normalizedValue) && normalizedValue is BsonArray normalizedArray
|
||||
? normalizedArray.OfType<BsonDocument>().Select(DeserializeNormalizedVersionRule).ToArray()
|
||||
: Array.Empty<NormalizedVersionRule>();
|
||||
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue is BsonArray provenanceArray
|
||||
? provenanceArray.OfType<BsonDocument>().Select(DeserializeProvenance).ToArray()
|
||||
: Array.Empty<AdvisoryProvenance>();
|
||||
|
||||
return new AffectedPackage(type, identifier, platform, versionRanges, statuses, provenance, normalizedVersions);
|
||||
}
|
||||
|
||||
private static AffectedVersionRange DeserializeVersionRange(BsonDocument document)
|
||||
{
|
||||
var rangeKind = document.GetValue("rangeKind", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("versionRanges.rangeKind missing from payload.");
|
||||
string? introducedVersion = document.TryGetValue("introducedVersion", out var introducedValue) && introducedValue.IsString ? introducedValue.AsString : null;
|
||||
string? fixedVersion = document.TryGetValue("fixedVersion", out var fixedValue) && fixedValue.IsString ? fixedValue.AsString : null;
|
||||
string? lastAffectedVersion = document.TryGetValue("lastAffectedVersion", out var lastValue) && lastValue.IsString ? lastValue.AsString : null;
|
||||
string? rangeExpression = document.TryGetValue("rangeExpression", out var expressionValue) && expressionValue.IsString ? expressionValue.AsString : null;
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument
|
||||
? DeserializeProvenance(provenanceValue.AsBsonDocument)
|
||||
: AdvisoryProvenance.Empty;
|
||||
RangePrimitives? primitives = null;
|
||||
if (document.TryGetValue("primitives", out var primitivesValue) && primitivesValue.IsBsonDocument)
|
||||
{
|
||||
primitives = DeserializePrimitives(primitivesValue.AsBsonDocument);
|
||||
}
|
||||
|
||||
return new AffectedVersionRange(rangeKind, introducedVersion, fixedVersion, lastAffectedVersion, rangeExpression, provenance, primitives);
|
||||
}
|
||||
|
||||
private static AffectedPackageStatus DeserializeStatus(BsonDocument document)
|
||||
{
|
||||
var status = document.GetValue("status", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("statuses.status missing from payload.");
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument
|
||||
? DeserializeProvenance(provenanceValue.AsBsonDocument)
|
||||
: AdvisoryProvenance.Empty;
|
||||
|
||||
return new AffectedPackageStatus(status, provenance);
|
||||
}
|
||||
|
||||
private static CvssMetric DeserializeCvssMetric(BsonDocument document)
|
||||
{
|
||||
var version = document.GetValue("version", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("cvssMetrics.version missing from payload.");
|
||||
var vector = document.GetValue("vector", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("cvssMetrics.vector missing from payload.");
|
||||
var baseScore = document.TryGetValue("baseScore", out var scoreValue) && scoreValue.IsNumeric ? scoreValue.ToDouble() : 0d;
|
||||
var baseSeverity = document.GetValue("baseSeverity", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("cvssMetrics.baseSeverity missing from payload.");
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument
|
||||
? DeserializeProvenance(provenanceValue.AsBsonDocument)
|
||||
: AdvisoryProvenance.Empty;
|
||||
|
||||
return new CvssMetric(version, vector, baseScore, baseSeverity, provenance);
|
||||
}
|
||||
|
||||
private static AdvisoryWeakness DeserializeWeakness(BsonDocument document)
|
||||
{
|
||||
var taxonomy = document.GetValue("taxonomy", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("cwes.taxonomy missing from payload.");
|
||||
var identifier = document.GetValue("identifier", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("cwes.identifier missing from payload.");
|
||||
string? name = document.TryGetValue("name", out var nameValue) && nameValue.IsString ? nameValue.AsString : null;
|
||||
string? uri = document.TryGetValue("uri", out var uriValue) && uriValue.IsString ? uriValue.AsString : null;
|
||||
var provenance = document.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument
|
||||
? DeserializeProvenance(provenanceValue.AsBsonDocument)
|
||||
: AdvisoryProvenance.Empty;
|
||||
|
||||
return new AdvisoryWeakness(taxonomy, identifier, name, uri, new[] { provenance });
|
||||
}
|
||||
|
||||
private static AdvisoryProvenance DeserializeProvenance(BsonDocument document)
|
||||
{
|
||||
var source = document.GetValue("source", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("provenance.source missing from payload.");
|
||||
var kind = document.GetValue("kind", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("provenance.kind missing from payload.");
|
||||
string? value = document.TryGetValue("value", out var valueElement) && valueElement.IsString ? valueElement.AsString : null;
|
||||
string? decisionReason = document.TryGetValue("decisionReason", out var reasonElement) && reasonElement.IsString ? reasonElement.AsString : null;
|
||||
var recordedAt = TryConvertDateTime(document.GetValue("recordedAt", defaultValue: null));
|
||||
IEnumerable<string>? fieldMask = null;
|
||||
if (document.TryGetValue("fieldMask", out var fieldMaskValue) && fieldMaskValue is BsonArray fieldMaskArray)
|
||||
{
|
||||
fieldMask = fieldMaskArray
|
||||
.OfType<BsonValue>()
|
||||
.Where(static element => element.IsString)
|
||||
.Select(static element => element.AsString);
|
||||
}
|
||||
|
||||
return new AdvisoryProvenance(
|
||||
source,
|
||||
kind,
|
||||
value ?? string.Empty,
|
||||
recordedAt ?? DateTimeOffset.UtcNow,
|
||||
fieldMask,
|
||||
decisionReason);
|
||||
}
|
||||
|
||||
private static NormalizedVersionRule DeserializeNormalizedVersionRule(BsonDocument document)
|
||||
{
|
||||
var scheme = document.GetValue("scheme", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("normalizedVersions.scheme missing from payload.");
|
||||
var type = document.GetValue("type", defaultValue: null)?.AsString
|
||||
?? throw new InvalidOperationException("normalizedVersions.type missing from payload.");
|
||||
|
||||
string? min = document.TryGetValue("min", out var minValue) && minValue.IsString ? minValue.AsString : null;
|
||||
bool? minInclusive = document.TryGetValue("minInclusive", out var minInclusiveValue) && minInclusiveValue.IsBoolean ? minInclusiveValue.AsBoolean : null;
|
||||
string? max = document.TryGetValue("max", out var maxValue) && maxValue.IsString ? maxValue.AsString : null;
|
||||
bool? maxInclusive = document.TryGetValue("maxInclusive", out var maxInclusiveValue) && maxInclusiveValue.IsBoolean ? maxInclusiveValue.AsBoolean : null;
|
||||
string? value = document.TryGetValue("value", out var valueElement) && valueElement.IsString ? valueElement.AsString : null;
|
||||
string? notes = document.TryGetValue("notes", out var notesValue) && notesValue.IsString ? notesValue.AsString : null;
|
||||
|
||||
return new NormalizedVersionRule(
|
||||
scheme,
|
||||
type,
|
||||
min,
|
||||
minInclusive,
|
||||
max,
|
||||
maxInclusive,
|
||||
value,
|
||||
notes);
|
||||
}
|
||||
|
||||
private static RangePrimitives? DeserializePrimitives(BsonDocument document)
|
||||
{
|
||||
SemVerPrimitive? semVer = null;
|
||||
NevraPrimitive? nevra = null;
|
||||
EvrPrimitive? evr = null;
|
||||
IReadOnlyDictionary<string, string>? vendor = null;
|
||||
|
||||
if (document.TryGetValue("semVer", out var semverValue) && semverValue.IsBsonDocument)
|
||||
{
|
||||
var semverDoc = semverValue.AsBsonDocument;
|
||||
semVer = new SemVerPrimitive(
|
||||
semverDoc.TryGetValue("introduced", out var semIntroduced) && semIntroduced.IsString ? semIntroduced.AsString : null,
|
||||
semverDoc.TryGetValue("introducedInclusive", out var semIntroducedInclusive) && semIntroducedInclusive.IsBoolean && semIntroducedInclusive.AsBoolean,
|
||||
semverDoc.TryGetValue("fixed", out var semFixed) && semFixed.IsString ? semFixed.AsString : null,
|
||||
semverDoc.TryGetValue("fixedInclusive", out var semFixedInclusive) && semFixedInclusive.IsBoolean && semFixedInclusive.AsBoolean,
|
||||
semverDoc.TryGetValue("lastAffected", out var semLast) && semLast.IsString ? semLast.AsString : null,
|
||||
semverDoc.TryGetValue("lastAffectedInclusive", out var semLastInclusive) && semLastInclusive.IsBoolean && semLastInclusive.AsBoolean,
|
||||
semverDoc.TryGetValue("constraintExpression", out var constraint) && constraint.IsString ? constraint.AsString : null,
|
||||
semverDoc.TryGetValue("exactValue", out var exact) && exact.IsString ? exact.AsString : null);
|
||||
}
|
||||
|
||||
if (document.TryGetValue("nevra", out var nevraValue) && nevraValue.IsBsonDocument)
|
||||
{
|
||||
var nevraDoc = nevraValue.AsBsonDocument;
|
||||
nevra = new NevraPrimitive(
|
||||
DeserializeNevraComponent(nevraDoc, "introduced"),
|
||||
DeserializeNevraComponent(nevraDoc, "fixed"),
|
||||
DeserializeNevraComponent(nevraDoc, "lastAffected"));
|
||||
}
|
||||
|
||||
if (document.TryGetValue("evr", out var evrValue) && evrValue.IsBsonDocument)
|
||||
{
|
||||
var evrDoc = evrValue.AsBsonDocument;
|
||||
evr = new EvrPrimitive(
|
||||
DeserializeEvrComponent(evrDoc, "introduced"),
|
||||
DeserializeEvrComponent(evrDoc, "fixed"),
|
||||
DeserializeEvrComponent(evrDoc, "lastAffected"));
|
||||
}
|
||||
|
||||
if (document.TryGetValue("vendorExtensions", out var vendorValue) && vendorValue.IsBsonDocument)
|
||||
{
|
||||
vendor = vendorValue.AsBsonDocument.Elements
|
||||
.Where(static e => e.Value.IsString)
|
||||
.ToDictionary(static e => e.Name, static e => e.Value.AsString, StringComparer.Ordinal);
|
||||
if (vendor.Count == 0)
|
||||
{
|
||||
vendor = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (semVer is null && nevra is null && evr is null && vendor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RangePrimitives(semVer, nevra, evr, vendor);
|
||||
}
|
||||
|
||||
private static NevraComponent? DeserializeNevraComponent(BsonDocument parent, string field)
|
||||
{
|
||||
if (!parent.TryGetValue(field, out var value) || !value.IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var component = value.AsBsonDocument;
|
||||
var name = component.TryGetValue("name", out var nameValue) && nameValue.IsString ? nameValue.AsString : null;
|
||||
var version = component.TryGetValue("version", out var versionValue) && versionValue.IsString ? versionValue.AsString : null;
|
||||
if (name is null || version is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var epoch = component.TryGetValue("epoch", out var epochValue) && epochValue.IsNumeric ? epochValue.ToInt32() : 0;
|
||||
var release = component.TryGetValue("release", out var releaseValue) && releaseValue.IsString ? releaseValue.AsString : string.Empty;
|
||||
var architecture = component.TryGetValue("architecture", out var archValue) && archValue.IsString ? archValue.AsString : null;
|
||||
|
||||
return new NevraComponent(name, epoch, version, release, architecture);
|
||||
}
|
||||
|
||||
private static EvrComponent? DeserializeEvrComponent(BsonDocument parent, string field)
|
||||
{
|
||||
if (!parent.TryGetValue(field, out var value) || !value.IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var component = value.AsBsonDocument;
|
||||
var epoch = component.TryGetValue("epoch", out var epochValue) && epochValue.IsNumeric ? epochValue.ToInt32() : 0;
|
||||
var upstream = component.TryGetValue("upstreamVersion", out var upstreamValue) && upstreamValue.IsString ? upstreamValue.AsString : null;
|
||||
if (upstream is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var revision = component.TryGetValue("revision", out var revisionValue) && revisionValue.IsString ? revisionValue.AsString : null;
|
||||
return new EvrComponent(epoch, upstream, revision);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryReadDateTime(BsonDocument document, string field)
|
||||
=> document.TryGetValue(field, out var value) ? TryConvertDateTime(value) : null;
|
||||
|
||||
private static DateTimeOffset? TryConvertDateTime(BsonValue? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
BsonDateTime dateTime => DateTime.SpecifyKind(dateTime.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonString stringValue when DateTimeOffset.TryParse(stringValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
|
||||
public interface IAdvisoryStore
|
||||
{
|
||||
Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class NormalizedVersionDocument
|
||||
{
|
||||
[BsonElement("packageId")]
|
||||
public string PackageId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("packageType")]
|
||||
public string PackageType { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("scheme")]
|
||||
public string Scheme { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("style")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Style { get; set; }
|
||||
|
||||
[BsonElement("min")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Min { get; set; }
|
||||
|
||||
[BsonElement("minInclusive")]
|
||||
[BsonIgnoreIfNull]
|
||||
public bool? MinInclusive { get; set; }
|
||||
|
||||
[BsonElement("max")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Max { get; set; }
|
||||
|
||||
[BsonElement("maxInclusive")]
|
||||
[BsonIgnoreIfNull]
|
||||
public bool? MaxInclusive { get; set; }
|
||||
|
||||
[BsonElement("value")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Value { get; set; }
|
||||
|
||||
[BsonElement("notes")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
[BsonElement("decisionReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DecisionReason { get; set; }
|
||||
|
||||
[BsonElement("constraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Constraint { get; set; }
|
||||
|
||||
[BsonElement("source")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Source { get; set; }
|
||||
|
||||
[BsonElement("recordedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTime? RecordedAtUtc { get; set; }
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
|
||||
internal static class NormalizedVersionDocumentFactory
|
||||
{
|
||||
public static List<NormalizedVersionDocument>? Create(Advisory advisory)
|
||||
{
|
||||
if (advisory.AffectedPackages.IsDefaultOrEmpty || advisory.AffectedPackages.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var documents = new List<NormalizedVersionDocument>();
|
||||
var advisoryFallbackReason = advisory.Provenance.FirstOrDefault()?.DecisionReason;
|
||||
var advisoryFallbackSource = advisory.Provenance.FirstOrDefault()?.Source;
|
||||
var advisoryFallbackRecordedAt = advisory.Provenance.FirstOrDefault()?.RecordedAt;
|
||||
|
||||
foreach (var package in advisory.AffectedPackages)
|
||||
{
|
||||
if (package.NormalizedVersions.IsDefaultOrEmpty || package.NormalizedVersions.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var rule in package.NormalizedVersions)
|
||||
{
|
||||
var matchingRange = FindMatchingRange(package, rule);
|
||||
var decisionReason = matchingRange?.Provenance.DecisionReason
|
||||
?? package.Provenance.FirstOrDefault()?.DecisionReason
|
||||
?? advisoryFallbackReason;
|
||||
|
||||
var source = matchingRange?.Provenance.Source
|
||||
?? package.Provenance.FirstOrDefault()?.Source
|
||||
?? advisoryFallbackSource;
|
||||
|
||||
var recordedAt = matchingRange?.Provenance.RecordedAt
|
||||
?? package.Provenance.FirstOrDefault()?.RecordedAt
|
||||
?? advisoryFallbackRecordedAt;
|
||||
|
||||
var constraint = matchingRange?.Primitives?.SemVer?.ConstraintExpression
|
||||
?? matchingRange?.RangeExpression;
|
||||
|
||||
var style = matchingRange?.Primitives?.SemVer?.Style ?? rule.Type;
|
||||
|
||||
documents.Add(new NormalizedVersionDocument
|
||||
{
|
||||
PackageId = package.Identifier ?? string.Empty,
|
||||
PackageType = package.Type ?? string.Empty,
|
||||
Scheme = rule.Scheme,
|
||||
Type = rule.Type,
|
||||
Style = style,
|
||||
Min = rule.Min,
|
||||
MinInclusive = rule.MinInclusive,
|
||||
Max = rule.Max,
|
||||
MaxInclusive = rule.MaxInclusive,
|
||||
Value = rule.Value,
|
||||
Notes = rule.Notes,
|
||||
DecisionReason = decisionReason,
|
||||
Constraint = constraint,
|
||||
Source = source,
|
||||
RecordedAtUtc = recordedAt?.UtcDateTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return documents.Count == 0 ? null : documents;
|
||||
}
|
||||
|
||||
private static AffectedVersionRange? FindMatchingRange(AffectedPackage package, NormalizedVersionRule rule)
|
||||
{
|
||||
foreach (var range in package.VersionRanges)
|
||||
{
|
||||
var candidate = range.ToNormalizedVersionRule(rule.Notes);
|
||||
if (candidate is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NormalizedRulesEquivalent(candidate, rule))
|
||||
{
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool NormalizedRulesEquivalent(NormalizedVersionRule left, NormalizedVersionRule right)
|
||||
=> string.Equals(left.Scheme, right.Scheme, StringComparison.Ordinal)
|
||||
&& string.Equals(left.Type, right.Type, StringComparison.Ordinal)
|
||||
&& string.Equals(left.Min, right.Min, StringComparison.Ordinal)
|
||||
&& left.MinInclusive == right.MinInclusive
|
||||
&& string.Equals(left.Max, right.Max, StringComparison.Ordinal)
|
||||
&& left.MaxInclusive == right.MaxInclusive
|
||||
&& string.Equals(left.Value, right.Value, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
internal sealed class AliasDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; }
|
||||
|
||||
[BsonElement("advisoryKey")]
|
||||
public string AdvisoryKey { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("scheme")]
|
||||
public string Scheme { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal static class AliasDocumentExtensions
|
||||
{
|
||||
public static AliasRecord ToRecord(this AliasDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var updatedAt = DateTime.SpecifyKind(document.UpdatedAt, DateTimeKind.Utc);
|
||||
return new AliasRecord(
|
||||
document.AdvisoryKey,
|
||||
document.Scheme,
|
||||
document.Value,
|
||||
new DateTimeOffset(updatedAt));
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
public sealed class AliasStore : IAliasStore
|
||||
{
|
||||
private readonly IMongoCollection<AliasDocument> _collection;
|
||||
private readonly ILogger<AliasStore> _logger;
|
||||
|
||||
public AliasStore(IMongoDatabase database, ILogger<AliasStore> logger)
|
||||
{
|
||||
_collection = (database ?? throw new ArgumentNullException(nameof(database)))
|
||||
.GetCollection<AliasDocument>(MongoStorageDefaults.Collections.Alias);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AliasUpsertResult> ReplaceAsync(
|
||||
string advisoryKey,
|
||||
IEnumerable<AliasEntry> aliases,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
|
||||
var aliasList = Normalize(aliases).ToArray();
|
||||
var deleteFilter = Builders<AliasDocument>.Filter.Eq(x => x.AdvisoryKey, advisoryKey);
|
||||
await _collection.DeleteManyAsync(deleteFilter, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (aliasList.Length > 0)
|
||||
{
|
||||
var documents = new List<AliasDocument>(aliasList.Length);
|
||||
var updatedAtUtc = updatedAt.ToUniversalTime().UtcDateTime;
|
||||
foreach (var alias in aliasList)
|
||||
{
|
||||
documents.Add(new AliasDocument
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
AdvisoryKey = advisoryKey,
|
||||
Scheme = alias.Scheme,
|
||||
Value = alias.Value,
|
||||
UpdatedAt = updatedAtUtc,
|
||||
});
|
||||
}
|
||||
|
||||
if (documents.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _collection.InsertManyAsync(
|
||||
documents,
|
||||
new InsertManyOptions { IsOrdered = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (MongoBulkWriteException<AliasDocument> ex) when (ex.WriteErrors.Any(error => error.Category == ServerErrorCategory.DuplicateKey))
|
||||
{
|
||||
foreach (var writeError in ex.WriteErrors.Where(error => error.Category == ServerErrorCategory.DuplicateKey))
|
||||
{
|
||||
var duplicateDocument = documents.ElementAtOrDefault(writeError.Index);
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Alias duplicate detected while inserting {Scheme}:{Value} for advisory {AdvisoryKey}. Existing aliases: {Existing}",
|
||||
duplicateDocument?.Scheme,
|
||||
duplicateDocument?.Value,
|
||||
duplicateDocument?.AdvisoryKey,
|
||||
string.Join(", ", aliasList.Select(a => $"{a.Scheme}:{a.Value}")));
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Alias duplicate detected while inserting aliases for advisory {AdvisoryKey}. Aliases: {Aliases}",
|
||||
advisoryKey,
|
||||
string.Join(", ", aliasList.Select(a => $"{a.Scheme}:{a.Value}")));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (aliasList.Length == 0)
|
||||
{
|
||||
return new AliasUpsertResult(advisoryKey, Array.Empty<AliasCollision>());
|
||||
}
|
||||
|
||||
var collisions = new List<AliasCollision>();
|
||||
foreach (var alias in aliasList)
|
||||
{
|
||||
var filter = Builders<AliasDocument>.Filter.Eq(x => x.Scheme, alias.Scheme)
|
||||
& Builders<AliasDocument>.Filter.Eq(x => x.Value, alias.Value);
|
||||
|
||||
using var cursor = await _collection.FindAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
foreach (var document in cursor.Current)
|
||||
{
|
||||
advisoryKeys.Add(document.AdvisoryKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (advisoryKeys.Count <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var collision = new AliasCollision(alias.Scheme, alias.Value, advisoryKeys.ToArray());
|
||||
collisions.Add(collision);
|
||||
AliasStoreMetrics.RecordCollision(alias.Scheme, advisoryKeys.Count);
|
||||
_logger.LogWarning(
|
||||
"Alias collision detected for {Scheme}:{Value}; advisories: {Advisories}",
|
||||
alias.Scheme,
|
||||
alias.Value,
|
||||
string.Join(", ", advisoryKeys));
|
||||
}
|
||||
|
||||
return new AliasUpsertResult(advisoryKey, collisions);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AliasRecord>> GetByAliasAsync(string scheme, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scheme);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
var normalizedScheme = NormalizeScheme(scheme);
|
||||
var normalizedValue = value.Trim();
|
||||
var filter = Builders<AliasDocument>.Filter.Eq(x => x.Scheme, normalizedScheme)
|
||||
& Builders<AliasDocument>.Filter.Eq(x => x.Value, normalizedValue);
|
||||
|
||||
var documents = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return documents.Select(static d => d.ToRecord()).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AliasRecord>> GetByAdvisoryAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
var filter = Builders<AliasDocument>.Filter.Eq(x => x.AdvisoryKey, advisoryKey);
|
||||
var documents = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return documents.Select(static d => d.ToRecord()).ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<AliasEntry> Normalize(IEnumerable<AliasEntry> aliases)
|
||||
{
|
||||
if (aliases is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (alias is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scheme = NormalizeScheme(alias.Scheme);
|
||||
var value = alias.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"{scheme}\u0001{value}";
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new AliasEntry(scheme, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeScheme(string scheme)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(scheme)
|
||||
? AliasStoreConstants.UnscopedScheme
|
||||
: scheme.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
public static class AliasStoreConstants
|
||||
{
|
||||
public const string PrimaryScheme = "PRIMARY";
|
||||
public const string UnscopedScheme = "UNSCOPED";
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
internal static class AliasStoreMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Concelier.Merge");
|
||||
|
||||
internal static readonly Counter<long> AliasCollisionCounter = Meter.CreateCounter<long>(
|
||||
"concelier.merge.alias_conflict",
|
||||
unit: "count",
|
||||
description: "Number of alias collisions detected when the same alias maps to multiple advisories.");
|
||||
|
||||
public static void RecordCollision(string scheme, int advisoryCount)
|
||||
{
|
||||
AliasCollisionCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("scheme", scheme),
|
||||
new KeyValuePair<string, object?>("advisory_count", advisoryCount));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
public interface IAliasStore
|
||||
{
|
||||
Task<AliasUpsertResult> ReplaceAsync(
|
||||
string advisoryKey,
|
||||
IEnumerable<AliasEntry> aliases,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<AliasRecord>> GetByAliasAsync(string scheme, string value, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<AliasRecord>> GetByAdvisoryAsync(string advisoryKey, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AliasEntry(string Scheme, string Value);
|
||||
|
||||
public sealed record AliasRecord(string AdvisoryKey, string Scheme, string Value, DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record AliasCollision(string Scheme, string Value, IReadOnlyList<string> AdvisoryKeys);
|
||||
|
||||
public sealed record AliasUpsertResult(string AdvisoryKey, IReadOnlyList<AliasCollision> Collisions);
|
||||
@@ -1,43 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ChangeHistoryDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("source")]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("advisoryKey")]
|
||||
public string AdvisoryKey { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("documentId")]
|
||||
public string DocumentId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("documentSha256")]
|
||||
public string DocumentSha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("currentHash")]
|
||||
public string CurrentHash { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("previousHash")]
|
||||
public string? PreviousHash { get; set; }
|
||||
|
||||
[BsonElement("currentSnapshot")]
|
||||
public string CurrentSnapshot { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("previousSnapshot")]
|
||||
public string? PreviousSnapshot { get; set; }
|
||||
|
||||
[BsonElement("changes")]
|
||||
public List<BsonDocument> Changes { get; set; } = new();
|
||||
|
||||
[BsonElement("capturedAt")]
|
||||
public DateTime CapturedAt { get; set; }
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
|
||||
internal static class ChangeHistoryDocumentExtensions
|
||||
{
|
||||
public static ChangeHistoryDocument ToDocument(this ChangeHistoryRecord record)
|
||||
{
|
||||
var changes = new List<BsonDocument>(record.Changes.Count);
|
||||
foreach (var change in record.Changes)
|
||||
{
|
||||
changes.Add(new BsonDocument
|
||||
{
|
||||
["field"] = change.Field,
|
||||
["type"] = change.ChangeType,
|
||||
["previous"] = change.PreviousValue is null ? BsonNull.Value : new BsonString(change.PreviousValue),
|
||||
["current"] = change.CurrentValue is null ? BsonNull.Value : new BsonString(change.CurrentValue),
|
||||
});
|
||||
}
|
||||
|
||||
return new ChangeHistoryDocument
|
||||
{
|
||||
Id = record.Id.ToString(),
|
||||
SourceName = record.SourceName,
|
||||
AdvisoryKey = record.AdvisoryKey,
|
||||
DocumentId = record.DocumentId.ToString(),
|
||||
DocumentSha256 = record.DocumentSha256,
|
||||
CurrentHash = record.CurrentHash,
|
||||
PreviousHash = record.PreviousHash,
|
||||
CurrentSnapshot = record.CurrentSnapshot,
|
||||
PreviousSnapshot = record.PreviousSnapshot,
|
||||
Changes = changes,
|
||||
CapturedAt = record.CapturedAt.UtcDateTime,
|
||||
};
|
||||
}
|
||||
|
||||
public static ChangeHistoryRecord ToRecord(this ChangeHistoryDocument document)
|
||||
{
|
||||
var changes = new List<ChangeHistoryFieldChange>(document.Changes.Count);
|
||||
foreach (var change in document.Changes)
|
||||
{
|
||||
var previousValue = change.TryGetValue("previous", out var previousBson) && previousBson is not BsonNull
|
||||
? previousBson.AsString
|
||||
: null;
|
||||
var currentValue = change.TryGetValue("current", out var currentBson) && currentBson is not BsonNull
|
||||
? currentBson.AsString
|
||||
: null;
|
||||
var fieldName = change.GetValue("field", "").AsString;
|
||||
var changeType = change.GetValue("type", "").AsString;
|
||||
changes.Add(new ChangeHistoryFieldChange(fieldName, changeType, previousValue, currentValue));
|
||||
}
|
||||
|
||||
var capturedAtUtc = DateTime.SpecifyKind(document.CapturedAt, DateTimeKind.Utc);
|
||||
|
||||
return new ChangeHistoryRecord(
|
||||
Guid.Parse(document.Id),
|
||||
document.SourceName,
|
||||
document.AdvisoryKey,
|
||||
Guid.Parse(document.DocumentId),
|
||||
document.DocumentSha256,
|
||||
document.CurrentHash,
|
||||
document.PreviousHash,
|
||||
document.CurrentSnapshot,
|
||||
document.PreviousSnapshot,
|
||||
changes,
|
||||
new DateTimeOffset(capturedAtUtc));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
|
||||
public sealed record ChangeHistoryFieldChange
|
||||
{
|
||||
public ChangeHistoryFieldChange(string field, string changeType, string? previousValue, string? currentValue)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(field);
|
||||
ArgumentException.ThrowIfNullOrEmpty(changeType);
|
||||
Field = field;
|
||||
ChangeType = changeType;
|
||||
PreviousValue = previousValue;
|
||||
CurrentValue = currentValue;
|
||||
}
|
||||
|
||||
public string Field { get; }
|
||||
|
||||
public string ChangeType { get; }
|
||||
|
||||
public string? PreviousValue { get; }
|
||||
|
||||
public string? CurrentValue { get; }
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
|
||||
public sealed class ChangeHistoryRecord
|
||||
{
|
||||
public ChangeHistoryRecord(
|
||||
Guid id,
|
||||
string sourceName,
|
||||
string advisoryKey,
|
||||
Guid documentId,
|
||||
string documentSha256,
|
||||
string currentHash,
|
||||
string? previousHash,
|
||||
string currentSnapshot,
|
||||
string? previousSnapshot,
|
||||
IReadOnlyList<ChangeHistoryFieldChange> changes,
|
||||
DateTimeOffset capturedAt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
ArgumentException.ThrowIfNullOrEmpty(documentSha256);
|
||||
ArgumentException.ThrowIfNullOrEmpty(currentHash);
|
||||
ArgumentException.ThrowIfNullOrEmpty(currentSnapshot);
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
|
||||
Id = id;
|
||||
SourceName = sourceName;
|
||||
AdvisoryKey = advisoryKey;
|
||||
DocumentId = documentId;
|
||||
DocumentSha256 = documentSha256;
|
||||
CurrentHash = currentHash;
|
||||
PreviousHash = previousHash;
|
||||
CurrentSnapshot = currentSnapshot;
|
||||
PreviousSnapshot = previousSnapshot;
|
||||
Changes = changes;
|
||||
CapturedAt = capturedAt;
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
public string SourceName { get; }
|
||||
|
||||
public string AdvisoryKey { get; }
|
||||
|
||||
public Guid DocumentId { get; }
|
||||
|
||||
public string DocumentSha256 { get; }
|
||||
|
||||
public string CurrentHash { get; }
|
||||
|
||||
public string? PreviousHash { get; }
|
||||
|
||||
public string CurrentSnapshot { get; }
|
||||
|
||||
public string? PreviousSnapshot { get; }
|
||||
|
||||
public IReadOnlyList<ChangeHistoryFieldChange> Changes { get; }
|
||||
|
||||
public DateTimeOffset CapturedAt { get; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
|
||||
public interface IChangeHistoryStore
|
||||
{
|
||||
Task AddAsync(ChangeHistoryRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ChangeHistoryRecord>> GetRecentAsync(string sourceName, string advisoryKey, int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.ChangeHistory;
|
||||
|
||||
public sealed class MongoChangeHistoryStore : IChangeHistoryStore
|
||||
{
|
||||
private readonly IMongoCollection<ChangeHistoryDocument> _collection;
|
||||
private readonly ILogger<MongoChangeHistoryStore> _logger;
|
||||
|
||||
public MongoChangeHistoryStore(IMongoDatabase database, ILogger<MongoChangeHistoryStore> logger)
|
||||
{
|
||||
_collection = (database ?? throw new ArgumentNullException(nameof(database)))
|
||||
.GetCollection<ChangeHistoryDocument>(MongoStorageDefaults.Collections.ChangeHistory);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task AddAsync(ChangeHistoryRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
var document = record.ToDocument();
|
||||
await _collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Recorded change history for {Source}/{Advisory} with hash {Hash}", record.SourceName, record.AdvisoryKey, record.CurrentHash);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChangeHistoryRecord>> GetRecentAsync(string sourceName, string advisoryKey, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
if (limit <= 0)
|
||||
{
|
||||
limit = 10;
|
||||
}
|
||||
|
||||
var cursor = await _collection.Find(x => x.SourceName == sourceName && x.AdvisoryKey == advisoryKey)
|
||||
.SortByDescending(x => x.CapturedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var records = new List<ChangeHistoryRecord>(cursor.Count);
|
||||
foreach (var document in cursor)
|
||||
{
|
||||
records.Add(document.ToRecord());
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Conflicts;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AdvisoryConflictDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = Guid.Empty.ToString("N");
|
||||
|
||||
[BsonElement("vulnerabilityKey")]
|
||||
public string VulnerabilityKey { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("conflictHash")]
|
||||
public byte[] ConflictHash { get; set; } = Array.Empty<byte>();
|
||||
|
||||
[BsonElement("asOf")]
|
||||
public DateTime AsOf { get; set; }
|
||||
|
||||
[BsonElement("recordedAt")]
|
||||
public DateTime RecordedAt { get; set; }
|
||||
|
||||
[BsonElement("statementIds")]
|
||||
public List<string> StatementIds { get; set; } = new();
|
||||
|
||||
[BsonElement("details")]
|
||||
public BsonDocument Details { get; set; } = new();
|
||||
|
||||
[BsonElement("provenance")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BsonDocument? Provenance { get; set; }
|
||||
|
||||
[BsonElement("trust")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BsonDocument? Trust { get; set; }
|
||||
}
|
||||
|
||||
internal static class AdvisoryConflictDocumentExtensions
|
||||
{
|
||||
public static AdvisoryConflictDocument FromRecord(AdvisoryConflictRecord record)
|
||||
=> new()
|
||||
{
|
||||
Id = record.Id.ToString(),
|
||||
VulnerabilityKey = record.VulnerabilityKey,
|
||||
ConflictHash = record.ConflictHash,
|
||||
AsOf = record.AsOf.UtcDateTime,
|
||||
RecordedAt = record.RecordedAt.UtcDateTime,
|
||||
StatementIds = record.StatementIds.Select(static id => id.ToString()).ToList(),
|
||||
Details = (BsonDocument)record.Details.DeepClone(),
|
||||
Provenance = record.Provenance is null ? null : (BsonDocument)record.Provenance.DeepClone(),
|
||||
Trust = record.Trust is null ? null : (BsonDocument)record.Trust.DeepClone(),
|
||||
};
|
||||
|
||||
public static AdvisoryConflictRecord ToRecord(this AdvisoryConflictDocument document)
|
||||
=> new(
|
||||
Guid.Parse(document.Id),
|
||||
document.VulnerabilityKey,
|
||||
document.ConflictHash,
|
||||
DateTime.SpecifyKind(document.AsOf, DateTimeKind.Utc),
|
||||
DateTime.SpecifyKind(document.RecordedAt, DateTimeKind.Utc),
|
||||
document.StatementIds.Select(static value => Guid.Parse(value)).ToList(),
|
||||
(BsonDocument)document.Details.DeepClone(),
|
||||
document.Provenance is null ? null : (BsonDocument)document.Provenance.DeepClone(),
|
||||
document.Trust is null ? null : (BsonDocument)document.Trust.DeepClone());
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Conflicts;
|
||||
|
||||
public sealed record AdvisoryConflictRecord(
|
||||
Guid Id,
|
||||
string VulnerabilityKey,
|
||||
byte[] ConflictHash,
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset RecordedAt,
|
||||
IReadOnlyList<Guid> StatementIds,
|
||||
BsonDocument Details,
|
||||
BsonDocument? Provenance = null,
|
||||
BsonDocument? Trust = null);
|
||||
@@ -1,93 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Conflicts;
|
||||
|
||||
public interface IAdvisoryConflictStore
|
||||
{
|
||||
ValueTask InsertAsync(
|
||||
IReadOnlyCollection<AdvisoryConflictRecord> conflicts,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AdvisoryConflictRecord>> GetConflictsAsync(
|
||||
string vulnerabilityKey,
|
||||
DateTimeOffset? asOf,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
public sealed class AdvisoryConflictStore : IAdvisoryConflictStore
|
||||
{
|
||||
private readonly IMongoCollection<AdvisoryConflictDocument> _collection;
|
||||
|
||||
public AdvisoryConflictStore(IMongoDatabase database)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
_collection = database.GetCollection<AdvisoryConflictDocument>(MongoStorageDefaults.Collections.AdvisoryConflicts);
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(
|
||||
IReadOnlyCollection<AdvisoryConflictRecord> conflicts,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(conflicts);
|
||||
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var documents = conflicts.Select(AdvisoryConflictDocumentExtensions.FromRecord).ToList();
|
||||
var options = new InsertManyOptions { IsOrdered = true };
|
||||
|
||||
try
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
await _collection.InsertManyAsync(documents, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _collection.InsertManyAsync(session, documents, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (MongoBulkWriteException ex) when (ex.WriteErrors.All(error => error.Category == ServerErrorCategory.DuplicateKey))
|
||||
{
|
||||
// Conflicts already persisted for this state; ignore duplicates.
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AdvisoryConflictRecord>> GetConflictsAsync(
|
||||
string vulnerabilityKey,
|
||||
DateTimeOffset? asOf,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityKey);
|
||||
|
||||
var filter = Builders<AdvisoryConflictDocument>.Filter.Eq(document => document.VulnerabilityKey, vulnerabilityKey);
|
||||
|
||||
if (asOf.HasValue)
|
||||
{
|
||||
filter &= Builders<AdvisoryConflictDocument>.Filter.Lte(document => document.AsOf, asOf.Value.UtcDateTime);
|
||||
}
|
||||
|
||||
var find = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
|
||||
var documents = await find
|
||||
.SortByDescending(document => document.AsOf)
|
||||
.ThenByDescending(document => document.RecordedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(static document => document.ToRecord()).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using System;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class DocumentDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("sourceName")]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("uri")]
|
||||
public string Uri { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("fetchedAt")]
|
||||
public DateTime FetchedAt { get; set; }
|
||||
|
||||
[BsonElement("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("contentType")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
[BsonElement("headers")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BsonDocument? Headers { get; set; }
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BsonDocument? Metadata { get; set; }
|
||||
|
||||
[BsonElement("etag")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Etag { get; set; }
|
||||
|
||||
[BsonElement("lastModified")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTime? LastModified { get; set; }
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
[BsonElement("gridFsId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public ObjectId? GridFsId { get; set; }
|
||||
}
|
||||
|
||||
internal static class DocumentDocumentExtensions
|
||||
{
|
||||
public static DocumentDocument FromRecord(DocumentRecord record)
|
||||
{
|
||||
return new DocumentDocument
|
||||
{
|
||||
Id = record.Id.ToString(),
|
||||
SourceName = record.SourceName,
|
||||
Uri = record.Uri,
|
||||
FetchedAt = record.FetchedAt.UtcDateTime,
|
||||
Sha256 = record.Sha256,
|
||||
Status = record.Status,
|
||||
ContentType = record.ContentType,
|
||||
Headers = ToBson(record.Headers),
|
||||
Metadata = ToBson(record.Metadata),
|
||||
Etag = record.Etag,
|
||||
LastModified = record.LastModified?.UtcDateTime,
|
||||
GridFsId = record.GridFsId,
|
||||
ExpiresAt = record.ExpiresAt?.UtcDateTime,
|
||||
};
|
||||
}
|
||||
|
||||
public static DocumentRecord ToRecord(this DocumentDocument document)
|
||||
{
|
||||
IReadOnlyDictionary<string, string>? headers = null;
|
||||
if (document.Headers is not null)
|
||||
{
|
||||
headers = document.Headers.Elements.ToDictionary(
|
||||
static e => e.Name,
|
||||
static e => e.Value?.ToString() ?? string.Empty,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, string>? metadata = null;
|
||||
if (document.Metadata is not null)
|
||||
{
|
||||
metadata = document.Metadata.Elements.ToDictionary(
|
||||
static e => e.Name,
|
||||
static e => e.Value?.ToString() ?? string.Empty,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return new DocumentRecord(
|
||||
Guid.Parse(document.Id),
|
||||
document.SourceName,
|
||||
document.Uri,
|
||||
DateTime.SpecifyKind(document.FetchedAt, DateTimeKind.Utc),
|
||||
document.Sha256,
|
||||
document.Status,
|
||||
document.ContentType,
|
||||
headers,
|
||||
metadata,
|
||||
document.Etag,
|
||||
document.LastModified.HasValue ? DateTime.SpecifyKind(document.LastModified.Value, DateTimeKind.Utc) : null,
|
||||
document.GridFsId,
|
||||
document.ExpiresAt.HasValue ? DateTime.SpecifyKind(document.ExpiresAt.Value, DateTimeKind.Utc) : null);
|
||||
}
|
||||
|
||||
private static BsonDocument? ToBson(IReadOnlyDictionary<string, string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var document = new BsonDocument();
|
||||
foreach (var kvp in values)
|
||||
{
|
||||
document[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
public sealed record DocumentRecord(
|
||||
Guid Id,
|
||||
string SourceName,
|
||||
string Uri,
|
||||
DateTimeOffset FetchedAt,
|
||||
string Sha256,
|
||||
string Status,
|
||||
string? ContentType,
|
||||
IReadOnlyDictionary<string, string>? Headers,
|
||||
IReadOnlyDictionary<string, string>? Metadata,
|
||||
string? Etag,
|
||||
DateTimeOffset? LastModified,
|
||||
ObjectId? GridFsId,
|
||||
DateTimeOffset? ExpiresAt = null)
|
||||
{
|
||||
public DocumentRecord WithStatus(string status)
|
||||
=> this with { Status = status };
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class DocumentStore : IDocumentStore
|
||||
{
|
||||
private readonly IMongoCollection<DocumentDocument> _collection;
|
||||
private readonly ILogger<DocumentStore> _logger;
|
||||
|
||||
public DocumentStore(IMongoDatabase database, ILogger<DocumentStore> logger)
|
||||
{
|
||||
_collection = (database ?? throw new ArgumentNullException(nameof(database)))
|
||||
.GetCollection<DocumentDocument>(MongoStorageDefaults.Collections.Document);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = DocumentDocumentExtensions.FromRecord(record);
|
||||
var filter = Builders<DocumentDocument>.Filter.Eq(x => x.SourceName, record.SourceName)
|
||||
& Builders<DocumentDocument>.Filter.Eq(x => x.Uri, record.Uri);
|
||||
|
||||
var options = new FindOneAndReplaceOptions<DocumentDocument>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After,
|
||||
};
|
||||
|
||||
var replaced = session is null
|
||||
? await _collection.FindOneAndReplaceAsync(filter, document, options, cancellationToken).ConfigureAwait(false)
|
||||
: await _collection.FindOneAndReplaceAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Upserted document {Source}/{Uri}", record.SourceName, record.Uri);
|
||||
return (replaced ?? document).ToRecord();
|
||||
}
|
||||
|
||||
public async Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
ArgumentException.ThrowIfNullOrEmpty(uri);
|
||||
|
||||
var filter = Builders<DocumentDocument>.Filter.Eq(x => x.SourceName, sourceName)
|
||||
& Builders<DocumentDocument>.Filter.Eq(x => x.Uri, uri);
|
||||
|
||||
var query = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
|
||||
var document = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToRecord();
|
||||
}
|
||||
|
||||
public async Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var idValue = id.ToString();
|
||||
var filter = Builders<DocumentDocument>.Filter.Eq(x => x.Id, idValue);
|
||||
var query = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
var document = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToRecord();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(status);
|
||||
|
||||
var update = Builders<DocumentDocument>.Update
|
||||
.Set(x => x.Status, status)
|
||||
.Set(x => x.LastModified, DateTime.UtcNow);
|
||||
|
||||
var idValue = id.ToString();
|
||||
var filter = Builders<DocumentDocument>.Filter.Eq(x => x.Id, idValue);
|
||||
UpdateResult result;
|
||||
if (session is null)
|
||||
{
|
||||
result = await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return result.MatchedCount > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
public interface IDocumentStore
|
||||
{
|
||||
Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class DtoDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("documentId")]
|
||||
public string DocumentId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("sourceName")]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("payload")]
|
||||
public BsonDocument Payload { get; set; } = new();
|
||||
|
||||
[BsonElement("validatedAt")]
|
||||
public DateTime ValidatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal static class DtoDocumentExtensions
|
||||
{
|
||||
public static DtoDocument FromRecord(DtoRecord record)
|
||||
=> new()
|
||||
{
|
||||
Id = record.Id.ToString(),
|
||||
DocumentId = record.DocumentId.ToString(),
|
||||
SourceName = record.SourceName,
|
||||
SchemaVersion = record.SchemaVersion,
|
||||
Payload = record.Payload ?? new BsonDocument(),
|
||||
ValidatedAt = record.ValidatedAt.UtcDateTime,
|
||||
};
|
||||
|
||||
public static DtoRecord ToRecord(this DtoDocument document)
|
||||
=> new(
|
||||
Guid.Parse(document.Id),
|
||||
Guid.Parse(document.DocumentId),
|
||||
document.SourceName,
|
||||
document.SchemaVersion,
|
||||
document.Payload,
|
||||
DateTime.SpecifyKind(document.ValidatedAt, DateTimeKind.Utc));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
public sealed record DtoRecord(
|
||||
Guid Id,
|
||||
Guid DocumentId,
|
||||
string SourceName,
|
||||
string SchemaVersion,
|
||||
BsonDocument Payload,
|
||||
DateTimeOffset ValidatedAt);
|
||||
@@ -1,66 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
public sealed class DtoStore : IDtoStore
|
||||
{
|
||||
private readonly IMongoCollection<DtoDocument> _collection;
|
||||
private readonly ILogger<DtoStore> _logger;
|
||||
|
||||
public DtoStore(IMongoDatabase database, ILogger<DtoStore> logger)
|
||||
{
|
||||
_collection = (database ?? throw new ArgumentNullException(nameof(database)))
|
||||
.GetCollection<DtoDocument>(MongoStorageDefaults.Collections.Dto);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DtoRecord> UpsertAsync(DtoRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = DtoDocumentExtensions.FromRecord(record);
|
||||
var documentId = record.DocumentId.ToString();
|
||||
var filter = Builders<DtoDocument>.Filter.Eq(x => x.DocumentId, documentId)
|
||||
& Builders<DtoDocument>.Filter.Eq(x => x.SourceName, record.SourceName);
|
||||
|
||||
var options = new FindOneAndReplaceOptions<DtoDocument>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After,
|
||||
};
|
||||
|
||||
var replaced = session is null
|
||||
? await _collection.FindOneAndReplaceAsync(filter, document, options, cancellationToken).ConfigureAwait(false)
|
||||
: await _collection.FindOneAndReplaceAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Upserted DTO for {Source}/{DocumentId}", record.SourceName, record.DocumentId);
|
||||
return (replaced ?? document).ToRecord();
|
||||
}
|
||||
|
||||
public async Task<DtoRecord?> FindByDocumentIdAsync(Guid documentId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var documentIdValue = documentId.ToString();
|
||||
var filter = Builders<DtoDocument>.Filter.Eq(x => x.DocumentId, documentIdValue);
|
||||
var query = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
var document = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToRecord();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DtoRecord>> GetBySourceAsync(string sourceName, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<DtoDocument>.Filter.Eq(x => x.SourceName, sourceName);
|
||||
var query = session is null
|
||||
? _collection.Find(filter)
|
||||
: _collection.Find(session, filter);
|
||||
|
||||
var cursor = await query
|
||||
.SortByDescending(x => x.ValidatedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return cursor.Select(static x => x.ToRecord()).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
|
||||
public interface IDtoStore
|
||||
{
|
||||
Task<DtoRecord> UpsertAsync(DtoRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<DtoRecord?> FindByDocumentIdAsync(Guid documentId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<IReadOnlyList<DtoRecord>> GetBySourceAsync(string sourceName, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Conflicts;
|
||||
using StellaOps.Concelier.Storage.Mongo.Statements;
|
||||
using StellaOps.Provenance.Mongo;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Events;
|
||||
|
||||
public sealed class MongoAdvisoryEventRepository : IAdvisoryEventRepository
|
||||
{
|
||||
private readonly IAdvisoryStatementStore _statementStore;
|
||||
private readonly IAdvisoryConflictStore _conflictStore;
|
||||
|
||||
public MongoAdvisoryEventRepository(
|
||||
IAdvisoryStatementStore statementStore,
|
||||
IAdvisoryConflictStore conflictStore)
|
||||
{
|
||||
_statementStore = statementStore ?? throw new ArgumentNullException(nameof(statementStore));
|
||||
_conflictStore = conflictStore ?? throw new ArgumentNullException(nameof(conflictStore));
|
||||
}
|
||||
|
||||
public async ValueTask InsertStatementsAsync(
|
||||
IReadOnlyCollection<AdvisoryStatementEntry> statements,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (statements is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(statements));
|
||||
}
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var records = statements
|
||||
.Select(static entry =>
|
||||
{
|
||||
var payload = BsonDocument.Parse(entry.CanonicalJson);
|
||||
var (provenanceDoc, trustDoc) = BuildMetadata(entry.Provenance, entry.Trust);
|
||||
|
||||
return new AdvisoryStatementRecord(
|
||||
entry.StatementId,
|
||||
entry.VulnerabilityKey,
|
||||
entry.AdvisoryKey,
|
||||
entry.StatementHash.ToArray(),
|
||||
entry.AsOf,
|
||||
entry.RecordedAt,
|
||||
payload,
|
||||
entry.InputDocumentIds.ToArray(),
|
||||
provenanceDoc,
|
||||
trustDoc);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await _statementStore.InsertAsync(records, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask InsertConflictsAsync(
|
||||
IReadOnlyCollection<AdvisoryConflictEntry> conflicts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (conflicts is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(conflicts));
|
||||
}
|
||||
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var records = conflicts
|
||||
.Select(static entry =>
|
||||
{
|
||||
var payload = BsonDocument.Parse(entry.CanonicalJson);
|
||||
var (provenanceDoc, trustDoc) = BuildMetadata(entry.Provenance, entry.Trust);
|
||||
|
||||
return new AdvisoryConflictRecord(
|
||||
entry.ConflictId,
|
||||
entry.VulnerabilityKey,
|
||||
entry.ConflictHash.ToArray(),
|
||||
entry.AsOf,
|
||||
entry.RecordedAt,
|
||||
entry.StatementIds.ToArray(),
|
||||
payload,
|
||||
provenanceDoc,
|
||||
trustDoc);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await _conflictStore.InsertAsync(records, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AdvisoryStatementEntry>> GetStatementsAsync(
|
||||
string vulnerabilityKey,
|
||||
DateTimeOffset? asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var records = await _statementStore
|
||||
.GetStatementsAsync(vulnerabilityKey, asOf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryStatementEntry>();
|
||||
}
|
||||
|
||||
var entries = records
|
||||
.Select(static record =>
|
||||
{
|
||||
var advisory = CanonicalJsonSerializer.Deserialize<Advisory>(record.Payload.ToJson());
|
||||
var canonicalJson = CanonicalJsonSerializer.Serialize(advisory);
|
||||
var (provenance, trust) = ParseMetadata(record.Provenance, record.Trust);
|
||||
|
||||
return new AdvisoryStatementEntry(
|
||||
record.Id,
|
||||
record.VulnerabilityKey,
|
||||
record.AdvisoryKey,
|
||||
canonicalJson,
|
||||
record.StatementHash.ToImmutableArray(),
|
||||
record.AsOf,
|
||||
record.RecordedAt,
|
||||
record.InputDocumentIds.ToImmutableArray(),
|
||||
provenance,
|
||||
trust);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AdvisoryConflictEntry>> GetConflictsAsync(
|
||||
string vulnerabilityKey,
|
||||
DateTimeOffset? asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var records = await _conflictStore
|
||||
.GetConflictsAsync(vulnerabilityKey, asOf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryConflictEntry>();
|
||||
}
|
||||
|
||||
var entries = records
|
||||
.Select(static record =>
|
||||
{
|
||||
var canonicalJson = Canonicalize(record.Details);
|
||||
var (provenance, trust) = ParseMetadata(record.Provenance, record.Trust);
|
||||
return new AdvisoryConflictEntry(
|
||||
record.Id,
|
||||
record.VulnerabilityKey,
|
||||
canonicalJson,
|
||||
record.ConflictHash.ToImmutableArray(),
|
||||
record.AsOf,
|
||||
record.RecordedAt,
|
||||
record.StatementIds.ToImmutableArray(),
|
||||
provenance,
|
||||
trust);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async ValueTask AttachStatementProvenanceAsync(
|
||||
Guid statementId,
|
||||
DsseProvenance dsse,
|
||||
TrustInfo trust,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dsse);
|
||||
ArgumentNullException.ThrowIfNull(trust);
|
||||
|
||||
var (provenanceDoc, trustDoc) = BuildMetadata(dsse, trust);
|
||||
|
||||
if (provenanceDoc is null || trustDoc is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to build provenance documents.");
|
||||
}
|
||||
|
||||
await _statementStore
|
||||
.UpdateProvenanceAsync(statementId, provenanceDoc, trustDoc, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false,
|
||||
};
|
||||
|
||||
private static string Canonicalize(BsonDocument document)
|
||||
{
|
||||
using var json = JsonDocument.Parse(document.ToJson());
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream, CanonicalWriterOptions))
|
||||
{
|
||||
WriteCanonical(json.RootElement, writer);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static (BsonDocument? Provenance, BsonDocument? Trust) BuildMetadata(DsseProvenance? provenance, TrustInfo? trust)
|
||||
{
|
||||
if (provenance is null || trust is null)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var metadata = new BsonDocument();
|
||||
metadata.AttachDsseProvenance(provenance, trust);
|
||||
|
||||
var provenanceDoc = metadata.TryGetValue("provenance", out var provenanceValue)
|
||||
? (BsonDocument)provenanceValue.DeepClone()
|
||||
: null;
|
||||
|
||||
var trustDoc = metadata.TryGetValue("trust", out var trustValue)
|
||||
? (BsonDocument)trustValue.DeepClone()
|
||||
: null;
|
||||
|
||||
return (provenanceDoc, trustDoc);
|
||||
}
|
||||
|
||||
private static (DsseProvenance?, TrustInfo?) ParseMetadata(BsonDocument? provenanceDoc, BsonDocument? trustDoc)
|
||||
{
|
||||
DsseProvenance? dsse = null;
|
||||
if (provenanceDoc is not null &&
|
||||
provenanceDoc.TryGetValue("dsse", out var dsseValue) &&
|
||||
dsseValue is BsonDocument dsseBody)
|
||||
{
|
||||
if (TryGetString(dsseBody, "envelopeDigest", out var envelopeDigest) &&
|
||||
TryGetString(dsseBody, "payloadType", out var payloadType) &&
|
||||
dsseBody.TryGetValue("key", out var keyValue) &&
|
||||
keyValue is BsonDocument keyDoc &&
|
||||
TryGetString(keyDoc, "keyId", out var keyId))
|
||||
{
|
||||
var keyInfo = new DsseKeyInfo
|
||||
{
|
||||
KeyId = keyId,
|
||||
Issuer = GetOptionalString(keyDoc, "issuer"),
|
||||
Algo = GetOptionalString(keyDoc, "algo"),
|
||||
};
|
||||
|
||||
dsse = new DsseProvenance
|
||||
{
|
||||
EnvelopeDigest = envelopeDigest,
|
||||
PayloadType = payloadType,
|
||||
Key = keyInfo,
|
||||
Rekor = ParseRekor(dsseBody),
|
||||
Chain = ParseChain(dsseBody)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
TrustInfo? trust = null;
|
||||
if (trustDoc is not null)
|
||||
{
|
||||
trust = new TrustInfo
|
||||
{
|
||||
Verified = trustDoc.TryGetValue("verified", out var verifiedValue) && verifiedValue.ToBoolean(),
|
||||
Verifier = GetOptionalString(trustDoc, "verifier"),
|
||||
Witnesses = trustDoc.TryGetValue("witnesses", out var witnessValue) && witnessValue.IsInt32 ? witnessValue.AsInt32 : (int?)null,
|
||||
PolicyScore = trustDoc.TryGetValue("policyScore", out var scoreValue) && scoreValue.IsNumeric ? scoreValue.AsDouble : (double?)null
|
||||
};
|
||||
}
|
||||
|
||||
return (dsse, trust);
|
||||
}
|
||||
|
||||
private static DsseRekorInfo? ParseRekor(BsonDocument dsseBody)
|
||||
{
|
||||
if (!dsseBody.TryGetValue("rekor", out var rekorValue) || !rekorValue.IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rekorDoc = rekorValue.AsBsonDocument;
|
||||
if (!TryGetInt64(rekorDoc, "logIndex", out var logIndex))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DsseRekorInfo
|
||||
{
|
||||
LogIndex = logIndex,
|
||||
Uuid = GetOptionalString(rekorDoc, "uuid") ?? string.Empty,
|
||||
IntegratedTime = TryGetInt64(rekorDoc, "integratedTime", out var integratedTime) ? integratedTime : null,
|
||||
MirrorSeq = TryGetInt64(rekorDoc, "mirrorSeq", out var mirrorSeq) ? mirrorSeq : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<DsseChainLink>? ParseChain(BsonDocument dsseBody)
|
||||
{
|
||||
if (!dsseBody.TryGetValue("chain", out var chainValue) || !chainValue.IsBsonArray)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var links = new List<DsseChainLink>();
|
||||
foreach (var element in chainValue.AsBsonArray)
|
||||
{
|
||||
if (!element.IsBsonDocument)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var linkDoc = element.AsBsonDocument;
|
||||
if (!TryGetString(linkDoc, "type", out var type) ||
|
||||
!TryGetString(linkDoc, "id", out var id) ||
|
||||
!TryGetString(linkDoc, "digest", out var digest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
links.Add(new DsseChainLink
|
||||
{
|
||||
Type = type,
|
||||
Id = id,
|
||||
Digest = digest
|
||||
});
|
||||
}
|
||||
|
||||
return links.Count == 0 ? null : links;
|
||||
}
|
||||
|
||||
private static bool TryGetString(BsonDocument document, string name, out string value)
|
||||
{
|
||||
if (document.TryGetValue(name, out var bsonValue) && bsonValue.IsString)
|
||||
{
|
||||
value = bsonValue.AsString;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? GetOptionalString(BsonDocument document, string name)
|
||||
=> document.TryGetValue(name, out var bsonValue) && bsonValue.IsString ? bsonValue.AsString : null;
|
||||
|
||||
private static bool TryGetInt64(BsonDocument document, string name, out long value)
|
||||
{
|
||||
if (document.TryGetValue(name, out var bsonValue))
|
||||
{
|
||||
if (bsonValue.IsInt64)
|
||||
{
|
||||
value = bsonValue.AsInt64;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (bsonValue.IsInt32)
|
||||
{
|
||||
value = bsonValue.AsInt32;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (bsonValue.IsString && long.TryParse(bsonValue.AsString, out var parsed))
|
||||
{
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(static p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(property.Value, writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(item, writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
break;
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
default:
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExportStateDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("baseExportId")]
|
||||
public string? BaseExportId { get; set; }
|
||||
|
||||
[BsonElement("baseDigest")]
|
||||
public string? BaseDigest { get; set; }
|
||||
|
||||
[BsonElement("lastFullDigest")]
|
||||
public string? LastFullDigest { get; set; }
|
||||
|
||||
[BsonElement("lastDeltaDigest")]
|
||||
public string? LastDeltaDigest { get; set; }
|
||||
|
||||
[BsonElement("exportCursor")]
|
||||
public string? ExportCursor { get; set; }
|
||||
|
||||
[BsonElement("targetRepo")]
|
||||
public string? TargetRepository { get; set; }
|
||||
|
||||
[BsonElement("exporterVersion")]
|
||||
public string? ExporterVersion { get; set; }
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
[BsonElement("files")]
|
||||
public List<ExportStateFileDocument>? Files { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExportStateFileDocument
|
||||
{
|
||||
[BsonElement("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("length")]
|
||||
public long Length { get; set; }
|
||||
|
||||
[BsonElement("digest")]
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal static class ExportStateDocumentExtensions
|
||||
{
|
||||
public static ExportStateDocument FromRecord(ExportStateRecord record)
|
||||
=> new()
|
||||
{
|
||||
Id = record.Id,
|
||||
BaseExportId = record.BaseExportId,
|
||||
BaseDigest = record.BaseDigest,
|
||||
LastFullDigest = record.LastFullDigest,
|
||||
LastDeltaDigest = record.LastDeltaDigest,
|
||||
ExportCursor = record.ExportCursor,
|
||||
TargetRepository = record.TargetRepository,
|
||||
ExporterVersion = record.ExporterVersion,
|
||||
UpdatedAt = record.UpdatedAt.UtcDateTime,
|
||||
Files = record.Files.Select(static file => new ExportStateFileDocument
|
||||
{
|
||||
Path = file.Path,
|
||||
Length = file.Length,
|
||||
Digest = file.Digest,
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
public static ExportStateRecord ToRecord(this ExportStateDocument document)
|
||||
=> new(
|
||||
document.Id,
|
||||
document.BaseExportId,
|
||||
document.BaseDigest,
|
||||
document.LastFullDigest,
|
||||
document.LastDeltaDigest,
|
||||
document.ExportCursor,
|
||||
document.TargetRepository,
|
||||
document.ExporterVersion,
|
||||
DateTime.SpecifyKind(document.UpdatedAt, DateTimeKind.Utc),
|
||||
(document.Files ?? new List<ExportStateFileDocument>())
|
||||
.Where(static entry => !string.IsNullOrWhiteSpace(entry.Path))
|
||||
.Select(static entry => new ExportFileRecord(entry.Path, entry.Length, entry.Digest))
|
||||
.ToArray());
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for exporters to read and persist their export metadata in Mongo-backed storage.
|
||||
/// </summary>
|
||||
public sealed class ExportStateManager
|
||||
{
|
||||
private readonly IExportStateStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ExportStateManager(IExportStateStore store, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ExportStateRecord?> GetAsync(string exporterId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(exporterId);
|
||||
return _store.FindAsync(exporterId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ExportStateRecord> StoreFullExportAsync(
|
||||
string exporterId,
|
||||
string exportId,
|
||||
string exportDigest,
|
||||
string? cursor,
|
||||
string? targetRepository,
|
||||
string exporterVersion,
|
||||
bool resetBaseline,
|
||||
IReadOnlyList<ExportFileRecord> manifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(exporterId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(exportId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(exportDigest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(exporterVersion);
|
||||
manifest ??= Array.Empty<ExportFileRecord>();
|
||||
|
||||
var existing = await _store.FindAsync(exporterId, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
var resolvedRepository = string.IsNullOrWhiteSpace(targetRepository) ? null : targetRepository;
|
||||
return await _store.UpsertAsync(
|
||||
new ExportStateRecord(
|
||||
exporterId,
|
||||
BaseExportId: exportId,
|
||||
BaseDigest: exportDigest,
|
||||
LastFullDigest: exportDigest,
|
||||
LastDeltaDigest: null,
|
||||
ExportCursor: cursor ?? exportDigest,
|
||||
TargetRepository: resolvedRepository,
|
||||
ExporterVersion: exporterVersion,
|
||||
UpdatedAt: now,
|
||||
Files: manifest),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var repositorySpecified = !string.IsNullOrWhiteSpace(targetRepository);
|
||||
var resolvedRepo = repositorySpecified ? targetRepository : existing.TargetRepository;
|
||||
var repositoryChanged = repositorySpecified
|
||||
&& !string.Equals(existing.TargetRepository, targetRepository, StringComparison.Ordinal);
|
||||
|
||||
var shouldResetBaseline =
|
||||
resetBaseline
|
||||
|| string.IsNullOrWhiteSpace(existing.BaseExportId)
|
||||
|| string.IsNullOrWhiteSpace(existing.BaseDigest)
|
||||
|| repositoryChanged;
|
||||
|
||||
var updatedRecord = shouldResetBaseline
|
||||
? existing with
|
||||
{
|
||||
BaseExportId = exportId,
|
||||
BaseDigest = exportDigest,
|
||||
LastFullDigest = exportDigest,
|
||||
LastDeltaDigest = null,
|
||||
ExportCursor = cursor ?? exportDigest,
|
||||
TargetRepository = resolvedRepo,
|
||||
ExporterVersion = exporterVersion,
|
||||
UpdatedAt = now,
|
||||
Files = manifest,
|
||||
}
|
||||
: existing with
|
||||
{
|
||||
LastFullDigest = exportDigest,
|
||||
LastDeltaDigest = null,
|
||||
ExportCursor = cursor ?? existing.ExportCursor,
|
||||
TargetRepository = resolvedRepo,
|
||||
ExporterVersion = exporterVersion,
|
||||
UpdatedAt = now,
|
||||
Files = manifest,
|
||||
};
|
||||
|
||||
return await _store.UpsertAsync(updatedRecord, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ExportStateRecord> StoreDeltaExportAsync(
|
||||
string exporterId,
|
||||
string deltaDigest,
|
||||
string? cursor,
|
||||
string exporterVersion,
|
||||
IReadOnlyList<ExportFileRecord> manifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(exporterId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(deltaDigest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(exporterVersion);
|
||||
manifest ??= Array.Empty<ExportFileRecord>();
|
||||
|
||||
var existing = await _store.FindAsync(exporterId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Full export state missing for '{exporterId}'.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = existing with
|
||||
{
|
||||
LastDeltaDigest = deltaDigest,
|
||||
ExportCursor = cursor ?? existing.ExportCursor,
|
||||
ExporterVersion = exporterVersion,
|
||||
UpdatedAt = now,
|
||||
Files = manifest,
|
||||
};
|
||||
|
||||
return await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
|
||||
public sealed record ExportStateRecord(
|
||||
string Id,
|
||||
string? BaseExportId,
|
||||
string? BaseDigest,
|
||||
string? LastFullDigest,
|
||||
string? LastDeltaDigest,
|
||||
string? ExportCursor,
|
||||
string? TargetRepository,
|
||||
string? ExporterVersion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyList<ExportFileRecord> Files);
|
||||
|
||||
public sealed record ExportFileRecord(string Path, long Length, string Digest);
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
|
||||
public sealed class ExportStateStore : IExportStateStore
|
||||
{
|
||||
private readonly IMongoCollection<ExportStateDocument> _collection;
|
||||
private readonly ILogger<ExportStateStore> _logger;
|
||||
|
||||
public ExportStateStore(IMongoDatabase database, ILogger<ExportStateStore> logger)
|
||||
{
|
||||
_collection = (database ?? throw new ArgumentNullException(nameof(database)))
|
||||
.GetCollection<ExportStateDocument>(MongoStorageDefaults.Collections.ExportState);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = ExportStateDocumentExtensions.FromRecord(record);
|
||||
var options = new FindOneAndReplaceOptions<ExportStateDocument>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After,
|
||||
};
|
||||
|
||||
var replaced = await _collection.FindOneAndReplaceAsync<ExportStateDocument, ExportStateDocument>(
|
||||
x => x.Id == record.Id,
|
||||
document,
|
||||
options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Stored export state {StateId}", record.Id);
|
||||
return (replaced ?? document).ToRecord();
|
||||
}
|
||||
|
||||
public async Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToRecord();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Exporting;
|
||||
|
||||
public interface IExportStateStore
|
||||
{
|
||||
Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo;
|
||||
|
||||
public interface ISourceStateRepository
|
||||
{
|
||||
Task<SourceStateRecord?> TryGetAsync(string sourceName, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<SourceStateRecord> UpsertAsync(SourceStateRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<SourceStateRecord?> UpdateCursorAsync(string sourceName, BsonDocument cursor, DateTimeOffset completedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
Task<SourceStateRecord?> MarkFailureAsync(string sourceName, DateTimeOffset failedAt, TimeSpan? backoff, string? failureReason, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class JobLeaseDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("holder")]
|
||||
public string Holder { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("acquiredAt")]
|
||||
public DateTime AcquiredAt { get; set; }
|
||||
|
||||
[BsonElement("heartbeatAt")]
|
||||
public DateTime HeartbeatAt { get; set; }
|
||||
|
||||
[BsonElement("leaseMs")]
|
||||
public long LeaseMs { get; set; }
|
||||
|
||||
[BsonElement("ttlAt")]
|
||||
public DateTime TtlAt { get; set; }
|
||||
}
|
||||
|
||||
internal static class JobLeaseDocumentExtensions
|
||||
{
|
||||
public static JobLease ToLease(this JobLeaseDocument document)
|
||||
=> new(
|
||||
document.Key,
|
||||
document.Holder,
|
||||
DateTime.SpecifyKind(document.AcquiredAt, DateTimeKind.Utc),
|
||||
DateTime.SpecifyKind(document.HeartbeatAt, DateTimeKind.Utc),
|
||||
TimeSpan.FromMilliseconds(document.LeaseMs),
|
||||
DateTime.SpecifyKind(document.TtlAt, DateTimeKind.Utc));
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class JobRunDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("kind")]
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = JobRunStatus.Pending.ToString();
|
||||
|
||||
[BsonElement("trigger")]
|
||||
public string Trigger { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("parameters")]
|
||||
public BsonDocument Parameters { get; set; } = new();
|
||||
|
||||
[BsonElement("parametersHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ParametersHash { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[BsonElement("startedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
[BsonElement("completedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
[BsonElement("error")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[BsonElement("timeoutMs")]
|
||||
[BsonIgnoreIfNull]
|
||||
public long? TimeoutMs { get; set; }
|
||||
|
||||
[BsonElement("leaseMs")]
|
||||
[BsonIgnoreIfNull]
|
||||
public long? LeaseMs { get; set; }
|
||||
}
|
||||
|
||||
internal static class JobRunDocumentExtensions
|
||||
{
|
||||
public static JobRunDocument FromRequest(JobRunCreateRequest request, Guid id)
|
||||
{
|
||||
return new JobRunDocument
|
||||
{
|
||||
Id = id.ToString(),
|
||||
Kind = request.Kind,
|
||||
Status = JobRunStatus.Pending.ToString(),
|
||||
Trigger = request.Trigger,
|
||||
Parameters = request.Parameters is { Count: > 0 }
|
||||
? BsonDocument.Parse(JsonSerializer.Serialize(request.Parameters))
|
||||
: new BsonDocument(),
|
||||
ParametersHash = request.ParametersHash,
|
||||
CreatedAt = request.CreatedAt.UtcDateTime,
|
||||
TimeoutMs = request.Timeout?.MillisecondsFromTimespan(),
|
||||
LeaseMs = request.LeaseDuration?.MillisecondsFromTimespan(),
|
||||
};
|
||||
}
|
||||
|
||||
public static JobRunSnapshot ToSnapshot(this JobRunDocument document)
|
||||
{
|
||||
var parameters = document.Parameters?.ToDictionary() ?? new Dictionary<string, object?>();
|
||||
|
||||
return new JobRunSnapshot(
|
||||
Guid.Parse(document.Id),
|
||||
document.Kind,
|
||||
Enum.Parse<JobRunStatus>(document.Status, ignoreCase: true),
|
||||
DateTime.SpecifyKind(document.CreatedAt, DateTimeKind.Utc),
|
||||
document.StartedAt.HasValue ? DateTime.SpecifyKind(document.StartedAt.Value, DateTimeKind.Utc) : null,
|
||||
document.CompletedAt.HasValue ? DateTime.SpecifyKind(document.CompletedAt.Value, DateTimeKind.Utc) : null,
|
||||
document.Trigger,
|
||||
document.ParametersHash,
|
||||
document.Error,
|
||||
document.TimeoutMs?.MillisecondsToTimespan(),
|
||||
document.LeaseMs?.MillisecondsToTimespan(),
|
||||
parameters);
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> ToDictionary(this BsonDocument document)
|
||||
{
|
||||
return document.Elements.ToDictionary(
|
||||
static element => element.Name,
|
||||
static element => element.Value switch
|
||||
{
|
||||
BsonString s => (object?)s.AsString,
|
||||
BsonBoolean b => b.AsBoolean,
|
||||
BsonInt32 i => i.AsInt32,
|
||||
BsonInt64 l => l.AsInt64,
|
||||
BsonDouble d => d.AsDouble,
|
||||
BsonNull => null,
|
||||
BsonArray array => array.Select(v => v.IsBsonDocument ? ToDictionary(v.AsBsonDocument) : (object?)v.ToString()).ToArray(),
|
||||
BsonDocument doc => ToDictionary(doc),
|
||||
_ => element.Value.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
private static long MillisecondsFromTimespan(this TimeSpan timeSpan)
|
||||
=> (long)timeSpan.TotalMilliseconds;
|
||||
|
||||
private static TimeSpan MillisecondsToTimespan(this long milliseconds)
|
||||
=> TimeSpan.FromMilliseconds(milliseconds);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.JpFlags;
|
||||
|
||||
public interface IJpFlagStore
|
||||
{
|
||||
Task UpsertAsync(JpFlagRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<JpFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.JpFlags;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class JpFlagDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonElement("advisoryKey")]
|
||||
public string AdvisoryKey { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("sourceName")]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("category")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Category { get; set; }
|
||||
|
||||
[BsonElement("vendorStatus")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VendorStatus { get; set; }
|
||||
|
||||
[BsonElement("recordedAt")]
|
||||
public DateTime RecordedAt { get; set; }
|
||||
}
|
||||
|
||||
internal static class JpFlagDocumentExtensions
|
||||
{
|
||||
public static JpFlagDocument FromRecord(JpFlagRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
return new JpFlagDocument
|
||||
{
|
||||
AdvisoryKey = record.AdvisoryKey,
|
||||
SourceName = record.SourceName,
|
||||
Category = record.Category,
|
||||
VendorStatus = record.VendorStatus,
|
||||
RecordedAt = record.RecordedAt.UtcDateTime,
|
||||
};
|
||||
}
|
||||
|
||||
public static JpFlagRecord ToRecord(this JpFlagDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
return new JpFlagRecord(
|
||||
document.AdvisoryKey,
|
||||
document.SourceName,
|
||||
document.Category,
|
||||
document.VendorStatus,
|
||||
DateTime.SpecifyKind(document.RecordedAt, DateTimeKind.Utc));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace StellaOps.Concelier.Storage.Mongo.JpFlags;
|
||||
|
||||
/// <summary>
|
||||
/// Captures Japan-specific enrichment flags derived from JVN payloads.
|
||||
/// </summary>
|
||||
public sealed record JpFlagRecord(
|
||||
string AdvisoryKey,
|
||||
string SourceName,
|
||||
string? Category,
|
||||
string? VendorStatus,
|
||||
DateTimeOffset RecordedAt)
|
||||
{
|
||||
public JpFlagRecord WithRecordedAt(DateTimeOffset recordedAt)
|
||||
=> this with { RecordedAt = recordedAt.ToUniversalTime() };
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.JpFlags;
|
||||
|
||||
public sealed class JpFlagStore : IJpFlagStore
|
||||
{
|
||||
private readonly IMongoCollection<JpFlagDocument> _collection;
|
||||
private readonly ILogger<JpFlagStore> _logger;
|
||||
|
||||
public JpFlagStore(IMongoDatabase database, ILogger<JpFlagStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_collection = database.GetCollection<JpFlagDocument>(MongoStorageDefaults.Collections.JpFlags);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(JpFlagRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var document = JpFlagDocumentExtensions.FromRecord(record);
|
||||
var filter = Builders<JpFlagDocument>.Filter.Eq(x => x.AdvisoryKey, record.AdvisoryKey);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await _collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Upserted jp_flag for {AdvisoryKey}", record.AdvisoryKey);
|
||||
}
|
||||
|
||||
public async Task<JpFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
|
||||
var filter = Builders<JpFlagDocument>.Filter.Eq(x => x.AdvisoryKey, advisoryKey);
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToRecord();
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Linksets;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AdvisoryLinksetDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; }
|
||||
= ObjectId.GenerateNewId();
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("source")]
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("advisoryId")]
|
||||
public string AdvisoryId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("observations")]
|
||||
public List<string> Observations { get; set; } = new();
|
||||
|
||||
[BsonElement("normalized")]
|
||||
[BsonIgnoreIfNull]
|
||||
public AdvisoryLinksetNormalizedDocument? Normalized { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("confidence")]
|
||||
[BsonIgnoreIfNull]
|
||||
public double? Confidence { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("conflicts")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<AdvisoryLinksetConflictDocument>? Conflicts { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[BsonElement("builtByJobId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? BuiltByJobId { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("provenance")]
|
||||
[BsonIgnoreIfNull]
|
||||
public AdvisoryLinksetProvenanceDocument? Provenance { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AdvisoryLinksetNormalizedDocument
|
||||
{
|
||||
[BsonElement("purls")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Purls { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("cpes")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Cpes { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("versions")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Versions { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("ranges")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Ranges { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("severities")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Severities { get; set; }
|
||||
= new();
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AdvisoryLinksetProvenanceDocument
|
||||
{
|
||||
[BsonElement("observationHashes")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? ObservationHashes { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("toolVersion")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ToolVersion { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("policyHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? PolicyHash { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AdvisoryLinksetConflictDocument
|
||||
{
|
||||
[BsonElement("field")]
|
||||
public string Field { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("values")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Values { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("sourceIds")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? SourceIds { get; set; }
|
||||
= new();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CoreLinksets = StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Linksets;
|
||||
|
||||
// Backcompat sink name retained for compile includes; forwards to the Mongo-specific store.
|
||||
internal sealed class AdvisoryLinksetSink : CoreLinksets.IAdvisoryLinksetSink
|
||||
{
|
||||
private readonly IMongoAdvisoryLinksetStore _store;
|
||||
|
||||
public AdvisoryLinksetSink(IMongoAdvisoryLinksetStore store)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public Task UpsertAsync(CoreLinksets.AdvisoryLinkset linkset, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
return _store.UpsertAsync(linkset, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Linksets;
|
||||
|
||||
internal sealed class ConcelierMongoLinksetSink : global::StellaOps.Concelier.Core.Linksets.IAdvisoryLinksetSink
|
||||
{
|
||||
private readonly IMongoAdvisoryLinksetStore _store;
|
||||
|
||||
public ConcelierMongoLinksetSink(IMongoAdvisoryLinksetStore store)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public Task UpsertAsync(global::StellaOps.Concelier.Core.Linksets.AdvisoryLinkset linkset, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
return _store.UpsertAsync(linkset, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using CoreLinksets = StellaOps.Concelier.Core.Linksets;
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Linksets;
|
||||
|
||||
// Storage implementation of advisory linkset persistence.
|
||||
internal sealed class ConcelierMongoLinksetStore : IMongoAdvisoryLinksetStore
|
||||
{
|
||||
private readonly IMongoCollection<AdvisoryLinksetDocument> _collection;
|
||||
|
||||
public ConcelierMongoLinksetStore(IMongoCollection<AdvisoryLinksetDocument> collection)
|
||||
{
|
||||
_collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(CoreLinksets.AdvisoryLinkset linkset, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
|
||||
var document = MapToDocument(linkset);
|
||||
var tenant = linkset.TenantId.ToLowerInvariant();
|
||||
var filter = Builders<AdvisoryLinksetDocument>.Filter.And(
|
||||
Builders<AdvisoryLinksetDocument>.Filter.Eq(d => d.TenantId, tenant),
|
||||
Builders<AdvisoryLinksetDocument>.Filter.Eq(d => d.Source, linkset.Source),
|
||||
Builders<AdvisoryLinksetDocument>.Filter.Eq(d => d.AdvisoryId, linkset.AdvisoryId));
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await _collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CoreLinksets.AdvisoryLinkset>> FindByTenantAsync(
|
||||
string tenantId,
|
||||
IEnumerable<string>? advisoryIds,
|
||||
IEnumerable<string>? sources,
|
||||
CoreLinksets.AdvisoryLinksetCursor? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
if (limit <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||
}
|
||||
|
||||
var builder = Builders<AdvisoryLinksetDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<AdvisoryLinksetDocument>>
|
||||
{
|
||||
builder.Eq(d => d.TenantId, tenantId.ToLowerInvariant())
|
||||
};
|
||||
|
||||
if (advisoryIds is not null)
|
||||
{
|
||||
var ids = advisoryIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToArray();
|
||||
if (ids.Length > 0)
|
||||
{
|
||||
filters.Add(builder.In(d => d.AdvisoryId, ids));
|
||||
}
|
||||
}
|
||||
|
||||
if (sources is not null)
|
||||
{
|
||||
var srcs = sources.Where(v => !string.IsNullOrWhiteSpace(v)).ToArray();
|
||||
if (srcs.Length > 0)
|
||||
{
|
||||
filters.Add(builder.In(d => d.Source, srcs));
|
||||
}
|
||||
}
|
||||
|
||||
var filter = builder.And(filters);
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
var cursorFilter = builder.Or(
|
||||
builder.Lt(d => d.CreatedAt, cursor.CreatedAt.UtcDateTime),
|
||||
builder.And(
|
||||
builder.Eq(d => d.CreatedAt, cursor.CreatedAt.UtcDateTime),
|
||||
builder.Gt(d => d.AdvisoryId, cursor.AdvisoryId)));
|
||||
|
||||
filter = builder.And(filter, cursorFilter);
|
||||
}
|
||||
|
||||
var sort = Builders<AdvisoryLinksetDocument>.Sort.Descending(d => d.CreatedAt).Ascending(d => d.AdvisoryId);
|
||||
var documents = await _collection.Find(filter)
|
||||
.Sort(sort)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(FromDocument).ToArray();
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetDocument MapToDocument(CoreLinksets.AdvisoryLinkset linkset)
|
||||
{
|
||||
return new AdvisoryLinksetDocument
|
||||
{
|
||||
TenantId = linkset.TenantId.ToLowerInvariant(),
|
||||
Source = linkset.Source,
|
||||
AdvisoryId = linkset.AdvisoryId,
|
||||
Observations = new List<string>(linkset.ObservationIds),
|
||||
CreatedAt = linkset.CreatedAt.UtcDateTime,
|
||||
BuiltByJobId = linkset.BuiltByJobId,
|
||||
Confidence = linkset.Confidence,
|
||||
Conflicts = linkset.Conflicts is null
|
||||
? null
|
||||
: linkset.Conflicts.Select(conflict => new AdvisoryLinksetConflictDocument
|
||||
{
|
||||
Field = conflict.Field,
|
||||
Reason = conflict.Reason,
|
||||
Values = conflict.Values is null ? null : new List<string>(conflict.Values),
|
||||
SourceIds = conflict.SourceIds is null ? null : new List<string>(conflict.SourceIds)
|
||||
}).ToList(),
|
||||
Provenance = linkset.Provenance is null ? null : new AdvisoryLinksetProvenanceDocument
|
||||
{
|
||||
ObservationHashes = linkset.Provenance.ObservationHashes is null
|
||||
? null
|
||||
: new List<string>(linkset.Provenance.ObservationHashes),
|
||||
ToolVersion = linkset.Provenance.ToolVersion,
|
||||
PolicyHash = linkset.Provenance.PolicyHash,
|
||||
},
|
||||
Normalized = linkset.Normalized is null ? null : new AdvisoryLinksetNormalizedDocument
|
||||
{
|
||||
Purls = linkset.Normalized.Purls is null ? null : new List<string>(linkset.Normalized.Purls),
|
||||
Cpes = linkset.Normalized.Cpes is null ? null : new List<string>(linkset.Normalized.Cpes),
|
||||
Versions = linkset.Normalized.Versions is null ? null : new List<string>(linkset.Normalized.Versions),
|
||||
Ranges = linkset.Normalized.RangesToBson(),
|
||||
Severities = linkset.Normalized.SeveritiesToBson(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static CoreLinksets.AdvisoryLinkset FromDocument(AdvisoryLinksetDocument doc)
|
||||
{
|
||||
return new CoreLinksets.AdvisoryLinkset(
|
||||
doc.TenantId,
|
||||
doc.Source,
|
||||
doc.AdvisoryId,
|
||||
doc.Observations.ToImmutableArray(),
|
||||
doc.Normalized is null ? null : new CoreLinksets.AdvisoryLinksetNormalized(
|
||||
doc.Normalized.Purls,
|
||||
doc.Normalized.Cpes,
|
||||
doc.Normalized.Versions,
|
||||
doc.Normalized.Ranges?.Select(ToDictionary).ToList(),
|
||||
doc.Normalized.Severities?.Select(ToDictionary).ToList()),
|
||||
doc.Provenance is null ? null : new CoreLinksets.AdvisoryLinksetProvenance(
|
||||
doc.Provenance.ObservationHashes,
|
||||
doc.Provenance.ToolVersion,
|
||||
doc.Provenance.PolicyHash),
|
||||
doc.Confidence,
|
||||
doc.Conflicts is null
|
||||
? null
|
||||
: doc.Conflicts.Select(conflict => new CoreLinksets.AdvisoryLinksetConflict(
|
||||
conflict.Field,
|
||||
conflict.Reason,
|
||||
conflict.Values,
|
||||
conflict.SourceIds)).ToList(),
|
||||
DateTime.SpecifyKind(doc.CreatedAt, DateTimeKind.Utc),
|
||||
doc.BuiltByJobId);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ToDictionary(MongoDB.Bson.BsonDocument bson)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var element in bson.Elements)
|
||||
{
|
||||
dict[element.Name] = element.Value switch
|
||||
{
|
||||
MongoDB.Bson.BsonString s => s.AsString,
|
||||
MongoDB.Bson.BsonInt32 i => i.AsInt32,
|
||||
MongoDB.Bson.BsonInt64 l => l.AsInt64,
|
||||
MongoDB.Bson.BsonDouble d => d.AsDouble,
|
||||
MongoDB.Bson.BsonDecimal128 dec => dec.ToDecimal(),
|
||||
MongoDB.Bson.BsonBoolean b => b.AsBoolean,
|
||||
MongoDB.Bson.BsonDateTime dt => dt.ToUniversalTime(),
|
||||
MongoDB.Bson.BsonNull => (object?)null,
|
||||
MongoDB.Bson.BsonArray arr => arr.Select(v => v.ToString()).ToArray(),
|
||||
_ => element.Value.ToString()
|
||||
};
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Linksets;
|
||||
|
||||
public interface IMongoAdvisoryLinksetStore : global::StellaOps.Concelier.Core.Linksets.IAdvisoryLinksetStore, global::StellaOps.Concelier.Core.Linksets.IAdvisoryLinksetLookup
|
||||
{
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
# Mongo Schema Migration Playbook
|
||||
|
||||
This module owns the persistent shape of Concelier's MongoDB database. Upgrades must be deterministic and safe to run on live replicas. The `MongoMigrationRunner` executes idempotent migrations on startup immediately after the bootstrapper completes its collection and index checks.
|
||||
|
||||
## Execution Path
|
||||
|
||||
1. `StellaOps.Concelier.WebService` calls `MongoBootstrapper.InitializeAsync()` during startup.
|
||||
2. Once collections and baseline indexes are ensured, the bootstrapper invokes `MongoMigrationRunner.RunAsync()`.
|
||||
3. Each `IMongoMigration` implementation is sorted by its `Id` (ordinal compare) and executed exactly once. Completion is recorded in the `schema_migrations` collection.
|
||||
4. Failures surface during startup and prevent the service from serving traffic, matching our "fail-fast" requirement for storage incompatibilities.
|
||||
|
||||
## Creating a Migration
|
||||
|
||||
1. Implement `IMongoMigration` under `StellaOps.Concelier.Storage.Mongo.Migrations`. Use a monotonically increasing identifier such as `yyyyMMdd_description`.
|
||||
2. Keep the body idempotent: query state first, drop/re-create indexes only when mismatch is detected, and avoid multi-document transactions unless required.
|
||||
3. Add the migration to DI in `ServiceCollectionExtensions` so it flows into the runner.
|
||||
4. Write an integration test that exercises the migration against a Mongo2Go instance to validate behaviour.
|
||||
|
||||
## Current Migrations
|
||||
|
||||
| Id | Description |
|
||||
| --- | --- |
|
||||
| `20241005_document_expiry_indexes` | Ensures `document` collection uses the correct TTL/partial index depending on raw document retention settings. |
|
||||
| `20241005_gridfs_expiry_indexes` | Aligns the GridFS `documents.files` TTL index with retention settings. |
|
||||
| `20251019_advisory_event_collections` | Creates/aligns indexes for `advisory_statements` and `advisory_conflicts` collections powering the event log + conflict replay pipeline. |
|
||||
| `20251028_advisory_raw_idempotency_index` | Applies compound unique index on `(source.vendor, upstream.upstream_id, upstream.content_hash, tenant)` after verifying no duplicates exist. |
|
||||
| `20251028_advisory_supersedes_backfill` | Renames legacy `advisory` collection to a read-only backup view and backfills `supersedes` chains across `advisory_raw`. |
|
||||
| `20251028_advisory_raw_validator` | Applies Aggregation-Only Contract JSON schema validator to the `advisory_raw` collection with configurable enforcement level. |
|
||||
| `20251104_advisory_observations_raw_linkset` | Backfills `rawLinkset` on `advisory_observations` using stored `advisory_raw` documents so canonical and raw projections co-exist for downstream policy joins. |
|
||||
| `20251120_advisory_observation_events` | Creates `advisory_observation_events` collection with tenant/hash indexes for observation event fan-out (advisory.observation.updated@1). Includes optional `publishedAt` marker for transport outbox. |
|
||||
| `20251117_advisory_linksets_tenant_lower` | Lowercases `advisory_linksets.tenantId` to align writes with lookup filters. |
|
||||
| `20251116_link_not_merge_collections` | Ensures `advisory_observations` and `advisory_linksets` collections exist with JSON schema validators and baseline indexes for LNM. |
|
||||
| `20251127_lnm_sharding_and_ttl` | Adds hashed shard key indexes on `tenantId` for horizontal scaling and optional TTL indexes on `ingestedAt`/`createdAt` for storage retention. Creates `advisory_linkset_events` collection for linkset event outbox (LNM-21-101-DEV). |
|
||||
| `20251127_lnm_legacy_backfill` | Backfills `advisory_observations` from `advisory_raw` documents and creates/updates `advisory_linksets` by grouping observations. Seeds `backfill_marker` tombstones on migrated documents for rollback tracking (LNM-21-102-DEV). |
|
||||
| `20251128_policy_delta_checkpoints` | Creates `policy_delta_checkpoints` collection with tenant/consumer indexes for deterministic policy delta tracking. Supports cursor-based pagination and change-stream resume tokens for policy consumers (CONCELIER-POLICY-20-003). |
|
||||
| `20251128_policy_lookup_indexes` | Adds secondary indexes for policy lookup patterns: alias multikey index on observations, confidence/severity indexes on linksets. Supports efficient policy joins without cached verdicts (CONCELIER-POLICY-23-001). |
|
||||
|
||||
## Operator Runbook
|
||||
|
||||
- `schema_migrations` records each applied migration (`_id`, `description`, `appliedAt`). Review this collection when auditing upgrades.
|
||||
- Prior to applying `20251028_advisory_raw_idempotency_index`, run the duplicate audit script against the target database:
|
||||
```bash
|
||||
mongo concelier ops/devops/scripts/check-advisory-raw-duplicates.js --eval 'var LIMIT=200;'
|
||||
```
|
||||
Resolve any reported rows before rolling out the migration.
|
||||
- After `20251028_advisory_supersedes_backfill` completes, ensure `db.advisory` reports `type: "view"` and `options.viewOn: "advisory_backup_20251028"`. Supersedes chains can be spot-checked via `db.advisory_raw.find({ supersedes: { $exists: true } }).limit(5)`.
|
||||
- To re-run a migration in a lab, delete the corresponding document from `schema_migrations` and restart the service. **Do not** do this in production unless the migration body is known to be idempotent and safe.
|
||||
- When changing retention settings (`RawDocumentRetention`), deploy the new configuration and restart Concelier. The migration runner will adjust indexes on the next boot.
|
||||
- For the event-log collections (`advisory_statements`, `advisory_conflicts`), rollback is simply `db.advisory_statements.drop()` / `db.advisory_conflicts.drop()` followed by a restart if you must revert to the pre-event-log schema (only in labs). Production rollbacks should instead gate merge features that rely on these collections.
|
||||
- For `20251127_lnm_legacy_backfill` rollback, use the provided Offline Kit script:
|
||||
```bash
|
||||
mongo concelier ops/devops/scripts/rollback-lnm-backfill.js
|
||||
```
|
||||
This script removes backfilled observations and linksets by querying the `backfill_marker` field (`lnm_21_102_dev`), then clears the tombstone markers from `advisory_raw`. After rollback, delete `20251127_lnm_legacy_backfill` from `schema_migrations` and restart.
|
||||
- If migrations fail, restart with `Logging__LogLevel__StellaOps.Concelier.Storage.Mongo.Migrations=Debug` to surface diagnostic output. Remediate underlying index/collection drift before retrying.
|
||||
|
||||
## Validating an Upgrade
|
||||
|
||||
1. Run `dotnet test --filter MongoMigrationRunnerTests` to exercise integration coverage.
|
||||
2. In staging, execute `db.schema_migrations.find().sort({_id:1})` to verify applied migrations and timestamps.
|
||||
3. Inspect index shapes: `db.document.getIndexes()` and `db.documents.files.getIndexes()` for TTL/partial filter alignment.
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
|
||||
public interface IMergeEventStore
|
||||
{
|
||||
Task AppendAsync(MergeEventRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<MergeEventRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user