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:
StellaOps Bot
2025-12-03 09:46:48 +02:00
parent e923880694
commit 35c8f9216f
520 changed files with 4416 additions and 31492 deletions

View File

@@ -1,6 +0,0 @@
namespace StellaOps.Authority.Storage.Mongo;
internal static class AuthorityMongoCollectionNames
{
public const string ServiceAccounts = "authority_service_accounts";
}

View File

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

View File

@@ -1,6 +0,0 @@
namespace StellaOps.Authority.Storage.Mongo;
public class Class1
{
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
namespace StellaOps.Concelier.Storage.Mongo.Aliases;
public static class AliasStoreConstants
{
public const string PrimaryScheme = "PRIMARY";
public const string UnscopedScheme = "UNSCOPED";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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