Refactor and update test projects, remove obsolete tests, and upgrade dependencies

- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory.
- Removed unused TestDataFactory class.
- Updated project files for Mongo.Tests to remove references to deleted files.
- Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects.
- Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project.
- Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library.
- Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries.
- Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious.
- Updated JsonSchema.Net package to version 7.3.2 in Microservice project.
- Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
master
2025-12-10 19:13:29 +02:00
parent a3c7fe5e88
commit b7059d523e
369 changed files with 11125 additions and 14245 deletions

View File

@@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E98A7C01-1619-41A0-A586-84EF9952F75D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\Concelier\__Libraries\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{BBB5CD3C-866A-4298-ACE1-598413631CF5}"
@@ -93,18 +91,6 @@ Global
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x64.Build.0 = Release|Any CPU
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.ActiveCfg = Release|Any CPU
{E98A7C01-1619-41A0-A586-84EF9952F75D}.Release|x86.Build.0 = Release|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.ActiveCfg = Debug|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x64.Build.0 = Debug|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.ActiveCfg = Debug|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Debug|x86.Build.0 = Debug|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|Any CPU.Build.0 = Release|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.ActiveCfg = Release|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x64.Build.0 = Release|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.ActiveCfg = Release|Any CPU
{973DD52D-AD3C-4526-92CB-F35FDD9AEA10}.Release|x86.Build.0 = Release|Any CPU
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7FB8ABD-31D7-4B4D-8B2A-F4D2B696ACAF}.Debug|x64.ActiveCfg = Debug|Any CPU

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />

View File

@@ -31,8 +31,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "StellaOps.Authority\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}"
@@ -209,18 +207,6 @@ Global
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x64.Build.0 = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x86.ActiveCfg = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x86.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x64.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x64.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x86.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x86.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|Any CPU.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x64.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x64.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x86.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x86.Build.0 = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -295,7 +281,6 @@ Global
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{614EDC46-4654-40F7-A779-8F127B8FD956} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{4B12E120-E39B-44A7-A25E-D3151D5AE914} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{168986E2-E127-4E03-BE45-4CC306E4E880} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{24BBDF59-7B30-4620-8464-BDACB1AEF49D} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}

View File

@@ -9,7 +9,7 @@
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -3,10 +3,10 @@ using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
using StellaOps.AirGap.Policy;
namespace StellaOps.Auth.Client;
@@ -35,21 +35,21 @@ public static class ServiceCollectionExtensions
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
EnsureEgressAllowed(provider, options, "authority-discovery");
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
}).AddResilienceHandler("authority-discovery", ConfigureResilience);
services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
EnsureEgressAllowed(provider, options, "authority-jwks");
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
}).AddResilienceHandler("authority-jwks", ConfigureResilience);
services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
EnsureEgressAllowed(provider, options, "authority-token");
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
}).AddResilienceHandler("authority-token", ConfigureResilience);
return services;
}
@@ -95,49 +95,19 @@ public static class ServiceCollectionExtensions
return builder;
}
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider)
private static void ConfigureResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = options.NormalizedRetryDelays;
if (delays.Count == 0)
builder.AddRetry(new HttpRetryStrategyOptions
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var logger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.Client.HttpRetry");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
if (logger is null)
{
return;
}
if (outcome.Exception is not null)
{
logger.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) after exception; waiting {Delay}.",
attempt,
delays.Count,
delay);
}
else
{
logger.LogWarning(
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) due to status {StatusCode}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Result!.StatusCode,
delay);
}
});
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = static args => ValueTask.FromResult(
args.Outcome.Exception is not null ||
args.Outcome.Result?.StatusCode is HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
or >= HttpStatusCode.InternalServerError)
});
}
private static void EnsureEgressAllowed(

View File

@@ -32,7 +32,7 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />

View File

@@ -11,11 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugin.Ldap\StellaOps.Authority.Plugin.Ldap.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -13,12 +13,13 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<!-- MongoDB.Driver removed - using Mongo compatibility shim -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,6 +10,6 @@
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<!-- MongoDB.Driver removed - using Mongo compatibility shim via Plugin.Standard project reference -->
</ItemGroup>
</Project>

View File

@@ -6,6 +6,8 @@ namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginOptions
{
public string? TenantId { get; set; }
public BootstrapUserOptions? BootstrapUser { get; set; }
public PasswordPolicyOptions PasswordPolicy { get; set; } = new();

View File

@@ -3,12 +3,12 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
@@ -16,6 +16,8 @@ namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
{
private const string DefaultTenantId = "default";
public string PluginType => "standard";
public void Register(AuthorityPluginRegistrationContext context)
@@ -27,12 +29,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
@@ -43,21 +45,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
})
.ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var userRepository = sp.GetRequiredService<IUserRepository>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
@@ -73,15 +75,19 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
// Use tenant from options or default
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
return new StandardUserCredentialStore(
pluginName,
tenantId,
userRepository,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{

View File

@@ -12,13 +12,13 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>

View File

@@ -2,45 +2,44 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserCredentialStore : IUserCredentialStore
{
private readonly IMongoCollection<StandardUserDocument> users;
private readonly IUserRepository userRepository;
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
private readonly string tenantId;
public StandardUserCredentialStore(
string pluginName,
IMongoDatabase database,
string tenantId,
IUserRepository userRepository,
StandardPluginOptions options,
IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
this.userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(database);
var collectionName = $"authority_users_{pluginName.ToLowerInvariant()}";
users = database.GetCollection<StandardUserDocument>(collectionName);
EnsureIndexes();
}
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
@@ -56,11 +55,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
var normalized = NormalizeUsername(username);
var user = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
var userEntity = await userRepository.GetByUsernameAsync(tenantId, normalized, cancellationToken)
.ConfigureAwait(false);
if (user is null)
if (userEntity is null)
{
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
await RecordAuditAsync(
@@ -74,7 +72,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
var user = MapToDocument(userEntity);
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
{
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
@@ -101,12 +101,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
auditProperties);
}
var verification = passwordHasher.Verify(password, user.PasswordHash);
var verification = passwordHasher.Verify(password, userEntity.PasswordHash ?? string.Empty);
if (verification is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded)
{
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = passwordHasher.Hash(password);
var newHash = passwordHasher.Hash(password);
await userRepository.UpdatePasswordAsync(tenantId, userEntity.Id, newHash, "", cancellationToken)
.ConfigureAwait(false);
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.rehashed",
@@ -114,13 +116,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
});
}
var previousFailures = user.Lockout.FailedAttempts;
ResetLockout(user);
user.UpdatedAt = DateTimeOffset.UtcNow;
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
var previousFailures = userEntity.FailedLoginAttempts;
await userRepository.RecordSuccessfulLoginAsync(tenantId, userEntity.Id, cancellationToken)
.ConfigureAwait(false);
if (previousFailures > 0)
{
@@ -146,23 +144,27 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
auditProperties);
}
await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
await RegisterFailureAsync(userEntity, cancellationToken).ConfigureAwait(false);
var code = options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockout
// Re-fetch to get updated lockout state
var updatedUser = await userRepository.GetByIdAsync(tenantId, userEntity.Id, cancellationToken)
.ConfigureAwait(false);
var code = options.Lockout.Enabled && updatedUser?.LockedUntil is { } lockout
? AuthorityCredentialFailureCode.LockedOut
: AuthorityCredentialFailureCode.InvalidCredentials;
TimeSpan? retry = user.Lockout.LockoutEnd is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
? lockoutTime - DateTimeOffset.UtcNow
: null;
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
Value = ClassifiedString.Public((updatedUser?.FailedLoginAttempts ?? 0).ToString(CultureInfo.InvariantCulture))
});
if (user.Lockout.LockoutEnd is { } pendingLockout)
if (updatedUser?.LockedUntil is { } pendingLockout)
{
auditProperties.Add(new AuthEventProperty
{
@@ -207,8 +209,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
}
var existing = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
var existing = await userRepository.GetByUsernameAsync(tenantId, normalized, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
@@ -218,57 +219,79 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_required", "New users require a password.");
}
var document = new StandardUserDocument
var metadata = new Dictionary<string, object?>
{
Username = registration.Username,
NormalizedUsername = normalized,
DisplayName = registration.DisplayName,
Email = registration.Email,
PasswordHash = passwordHasher.Hash(registration.Password!),
RequirePasswordReset = registration.RequirePasswordReset,
Roles = registration.Roles.ToList(),
Attributes = new Dictionary<string, string?>(registration.Attributes, StringComparer.OrdinalIgnoreCase),
CreatedAt = now,
UpdatedAt = now
["subjectId"] = Guid.NewGuid().ToString("N"),
["roles"] = registration.Roles.ToList(),
["attributes"] = registration.Attributes,
["requirePasswordReset"] = registration.RequirePasswordReset
};
await users.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(document));
var newUser = new UserEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Username = normalized,
Email = registration.Email ?? $"{normalized}@local",
DisplayName = registration.DisplayName,
PasswordHash = passwordHasher.Hash(registration.Password!),
PasswordSalt = "",
Enabled = true,
Metadata = JsonSerializer.Serialize(metadata)
};
var created = await userRepository.CreateAsync(newUser, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(MapToDocument(created)));
}
existing.Username = registration.Username;
existing.DisplayName = registration.DisplayName ?? existing.DisplayName;
existing.Email = registration.Email ?? existing.Email;
existing.Roles = registration.Roles.Any()
? registration.Roles.ToList()
: existing.Roles;
// Update existing user
var existingMetadata = ParseMetadata(existing.Metadata);
if (registration.Roles.Any())
{
existingMetadata["roles"] = registration.Roles.ToList();
}
if (registration.Attributes.Count > 0)
{
var attrs = existingMetadata.TryGetValue("attributes", out var existingAttrs) && existingAttrs is Dictionary<string, string?> dict
? dict
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in registration.Attributes)
{
existing.Attributes[pair.Key] = pair.Value;
attrs[pair.Key] = pair.Value;
}
existingMetadata["attributes"] = attrs;
}
if (!string.IsNullOrEmpty(registration.Password))
{
existing.PasswordHash = passwordHasher.Hash(registration.Password!);
existing.RequirePasswordReset = registration.RequirePasswordReset;
await userRepository.UpdatePasswordAsync(tenantId, existing.Id, passwordHasher.Hash(registration.Password!), "", cancellationToken)
.ConfigureAwait(false);
existingMetadata["requirePasswordReset"] = registration.RequirePasswordReset;
}
else if (registration.RequirePasswordReset)
{
existing.RequirePasswordReset = true;
existingMetadata["requirePasswordReset"] = true;
}
existing.UpdatedAt = now;
var updatedUser = new UserEntity
{
Id = existing.Id,
TenantId = tenantId,
Username = normalized,
Email = registration.Email ?? existing.Email,
DisplayName = registration.DisplayName ?? existing.DisplayName,
PasswordHash = existing.PasswordHash,
PasswordSalt = existing.PasswordSalt,
Enabled = existing.Enabled,
Metadata = JsonSerializer.Serialize(existingMetadata)
};
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, existing.Id),
existing,
cancellationToken: cancellationToken).ConfigureAwait(false);
await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(existing));
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(MapToDocument(updatedUser, existingMetadata)));
}
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
@@ -278,11 +301,21 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return null;
}
var user = await users.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
// We need to search by subjectId which is stored in metadata
// For now, get all users and filter - in production, add a dedicated query
var users = await userRepository.GetAllAsync(tenantId, enabled: null, limit: 1000, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return user is null ? null : ToDescriptor(user);
foreach (var user in users)
{
var metadata = ParseMetadata(user.Metadata);
if (metadata.TryGetValue("subjectId", out var sid) && sid?.ToString() == subjectId)
{
return ToDescriptor(MapToDocument(user, metadata));
}
}
return null;
}
public async Task EnsureBootstrapUserAsync(BootstrapUserOptions bootstrap, CancellationToken cancellationToken)
@@ -312,19 +345,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
}
public async Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
public Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
{
try
{
var command = new BsonDocument("ping", 1);
await users.Database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginHealthResult.Healthy();
}
catch (Exception ex)
{
logger.LogError(ex, "Plugin {PluginName} failed MongoDB health check.", pluginName);
return AuthorityPluginHealthResult.Unavailable(ex.Message);
}
// PostgreSQL health is checked at infrastructure level
return Task.FromResult(AuthorityPluginHealthResult.Healthy());
}
private string? ValidatePassword(string password)
@@ -357,33 +381,76 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return null;
}
private async Task RegisterFailureAsync(StandardUserDocument user, CancellationToken cancellationToken)
private async Task RegisterFailureAsync(UserEntity user, CancellationToken cancellationToken)
{
user.Lockout.LastFailure = DateTimeOffset.UtcNow;
user.Lockout.FailedAttempts += 1;
DateTimeOffset? lockUntil = null;
if (options.Lockout.Enabled && user.Lockout.FailedAttempts >= options.Lockout.MaxAttempts)
if (options.Lockout.Enabled && user.FailedLoginAttempts + 1 >= options.Lockout.MaxAttempts)
{
user.Lockout.LockoutEnd = DateTimeOffset.UtcNow + options.Lockout.Window;
user.Lockout.FailedAttempts = 0;
lockUntil = DateTimeOffset.UtcNow + options.Lockout.Window;
}
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static void ResetLockout(StandardUserDocument user)
{
user.Lockout.FailedAttempts = 0;
user.Lockout.LockoutEnd = null;
user.Lockout.LastFailure = null;
await userRepository.RecordFailedLoginAsync(tenantId, user.Id, lockUntil, cancellationToken)
.ConfigureAwait(false);
}
private static string NormalizeUsername(string username)
=> username.Trim().ToLowerInvariant();
private static StandardUserDocument MapToDocument(UserEntity entity, Dictionary<string, object?>? metadata = null)
{
metadata ??= ParseMetadata(entity.Metadata);
var subjectId = metadata.TryGetValue("subjectId", out var sid) ? sid?.ToString() ?? entity.Id.ToString("N") : entity.Id.ToString("N");
var roles = metadata.TryGetValue("roles", out var r) && r is JsonElement rolesElement
? rolesElement.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList()
: new List<string>();
var attrs = metadata.TryGetValue("attributes", out var a) && a is JsonElement attrsElement
? attrsElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString(), StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
var requireReset = metadata.TryGetValue("requirePasswordReset", out var rr) && rr is JsonElement rrElement && rrElement.GetBoolean();
return new StandardUserDocument
{
Id = entity.Id,
SubjectId = subjectId,
Username = entity.Username,
NormalizedUsername = entity.Username.ToLowerInvariant(),
PasswordHash = entity.PasswordHash ?? string.Empty,
DisplayName = entity.DisplayName,
Email = entity.Email,
RequirePasswordReset = requireReset,
Roles = roles,
Attributes = attrs!,
Lockout = new StandardLockoutState
{
FailedAttempts = entity.FailedLoginAttempts,
LockoutEnd = entity.LockedUntil,
LastFailure = entity.FailedLoginAttempts > 0 ? entity.UpdatedAt : null
},
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
private static Dictionary<string, object?> ParseMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "{}")
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
catch
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
}
private AuthorityUserDescriptor ToDescriptor(StandardUserDocument document)
=> new(
document.SubjectId,
@@ -393,25 +460,6 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
document.Roles,
document.Attributes);
private void EnsureIndexes()
{
var indexKeys = Builders<StandardUserDocument>.IndexKeys
.Ascending(u => u.NormalizedUsername);
var indexModel = new CreateIndexModel<StandardUserDocument>(
indexKeys,
new CreateIndexOptions { Unique = true, Name = "idx_normalized_username" });
try
{
users.Indexes.CreateOne(indexModel);
}
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
}
}
private async ValueTask RecordAuditAsync(
string normalizedUsername,
string? subjectId,

View File

@@ -1,64 +1,42 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserDocument
{
[BsonId]
public ObjectId Id { get; set; }
public Guid Id { get; set; } = Guid.NewGuid();
[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("passwordHash")]
public string PasswordHash { get; set; } = string.Empty;
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("email")]
[BsonIgnoreIfNull]
public string? Email { get; set; }
[BsonElement("requirePasswordReset")]
public bool RequirePasswordReset { get; set; }
[BsonElement("roles")]
public List<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("lockout")]
public StandardLockoutState Lockout { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
internal sealed class StandardLockoutState
{
[BsonElement("failedAttempts")]
public int FailedAttempts { get; set; }
[BsonElement("lockoutEnd")]
[BsonIgnoreIfNull]
public DateTimeOffset? LockoutEnd { get; set; }
[BsonElement("lastFailure")]
[BsonIgnoreIfNull]
public DateTimeOffset? LastFailure { get; set; }
}

View File

@@ -0,0 +1,60 @@
using MongoDB.Bson;
namespace MongoDB.Bson.Serialization.Attributes;
/// <summary>
/// Compatibility shim for MongoDB BsonId attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIdAttribute : Attribute
{
}
/// <summary>
/// Compatibility shim for MongoDB BsonElement attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonElementAttribute : Attribute
{
public string ElementName { get; }
public BsonElementAttribute(string elementName)
{
ElementName = elementName;
}
}
/// <summary>
/// Compatibility shim for MongoDB BsonIgnore attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIgnoreAttribute : Attribute
{
}
/// <summary>
/// Compatibility shim for MongoDB BsonIgnoreIfNull attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIgnoreIfNullAttribute : Attribute
{
}
/// <summary>
/// Compatibility shim for MongoDB BsonRepresentation attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonRepresentationAttribute : Attribute
{
public BsonType Representation { get; }
public BsonRepresentationAttribute(BsonType representation)
{
Representation = representation;
}
}

View File

@@ -0,0 +1,79 @@
namespace MongoDB.Bson;
/// <summary>
/// Compatibility shim for MongoDB ObjectId.
/// In PostgreSQL mode, this wraps a GUID string.
/// </summary>
public readonly struct ObjectId : IEquatable<ObjectId>, IComparable<ObjectId>
{
private readonly string _value;
public static readonly ObjectId Empty = new(string.Empty);
public ObjectId(string value)
{
_value = value ?? string.Empty;
}
public static ObjectId GenerateNewId()
{
return new ObjectId(Guid.NewGuid().ToString("N"));
}
public static ObjectId Parse(string s)
{
return new ObjectId(s);
}
public static bool TryParse(string s, out ObjectId result)
{
result = new ObjectId(s);
return true;
}
public override string ToString() => _value;
public bool Equals(ObjectId other) => _value == other._value;
public override bool Equals(object? obj) => obj is ObjectId other && Equals(other);
public override int GetHashCode() => _value?.GetHashCode() ?? 0;
public int CompareTo(ObjectId other) => string.Compare(_value, other._value, StringComparison.Ordinal);
public static bool operator ==(ObjectId left, ObjectId right) => left.Equals(right);
public static bool operator !=(ObjectId left, ObjectId right) => !left.Equals(right);
public static implicit operator string(ObjectId id) => id._value;
public static implicit operator ObjectId(string value) => new(value);
}
/// <summary>
/// Compatibility shim for MongoDB BsonType enum.
/// </summary>
public enum BsonType
{
EndOfDocument = 0,
Double = 1,
String = 2,
Document = 3,
Array = 4,
Binary = 5,
Undefined = 6,
ObjectId = 7,
Boolean = 8,
DateTime = 9,
Null = 10,
RegularExpression = 11,
JavaScript = 13,
Symbol = 14,
JavaScriptWithScope = 15,
Int32 = 16,
Timestamp = 17,
Int64 = 18,
Decimal128 = 19,
MinKey = -1,
MaxKey = 127
}

View File

@@ -0,0 +1,183 @@
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents a bootstrap invite document.
/// </summary>
public sealed class AuthorityBootstrapInviteDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Token { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string? Provider { get; set; }
public string? Target { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public bool Consumed { get; set; }
}
/// <summary>
/// Represents a service account document.
/// </summary>
public sealed class AuthorityServiceAccountDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string AccountId { get; set; } = string.Empty;
public string Tenant { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Description { get; set; }
public bool Enabled { get; set; } = true;
public List<string> AllowedScopes { get; set; } = new();
public List<string> AuthorizedClients { get; set; } = new();
public Dictionary<string, string> Attributes { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a client document.
/// </summary>
public sealed class AuthorityClientDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; }
public string? SecretHash { get; set; }
public string? DisplayName { get; set; }
public string? Description { get; set; }
public string? Plugin { get; set; }
public string? SenderConstraint { get; set; }
public bool Enabled { get; set; } = true;
public List<string> RedirectUris { get; set; } = new();
public List<string> PostLogoutRedirectUris { get; set; } = new();
public List<string> AllowedScopes { get; set; } = new();
public List<string> AllowedGrantTypes { get; set; } = new();
public bool RequireClientSecret { get; set; } = true;
public bool RequirePkce { get; set; }
public bool AllowPlainTextPkce { get; set; }
public string? ClientType { get; set; }
public Dictionary<string, string> Properties { get; set; } = new();
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a revocation document.
/// </summary>
public sealed class AuthorityRevocationDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Category { get; set; } = string.Empty;
public string RevocationId { get; set; } = string.Empty;
public string SubjectId { get; set; } = string.Empty;
public string? ClientId { get; set; }
public string? TokenId { get; set; }
public string Reason { get; set; } = string.Empty;
public string? ReasonDescription { get; set; }
public DateTimeOffset RevokedAt { get; set; }
public DateTimeOffset EffectiveAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public Dictionary<string, string?> Metadata { get; set; } = new();
}
/// <summary>
/// Represents a login attempt document.
/// </summary>
public sealed class AuthorityLoginAttemptDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string? SubjectId { get; set; }
public string? ClientId { get; set; }
public string EventType { get; set; } = string.Empty;
public string Outcome { get; set; } = string.Empty;
public string? Reason { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public List<AuthorityLoginAttemptPropertyDocument> Properties { get; set; } = new();
}
/// <summary>
/// Represents a property in a login attempt document.
/// </summary>
public sealed class AuthorityLoginAttemptPropertyDocument
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool Sensitive { get; set; }
}
/// <summary>
/// Represents a token document.
/// </summary>
public sealed class AuthorityTokenDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TokenId { get; set; } = string.Empty;
public string? SubjectId { get; set; }
public string? ClientId { get; set; }
public string TokenType { get; set; } = string.Empty;
public string? ReferenceId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset? RedeemedAt { get; set; }
public string? Payload { get; set; }
public Dictionary<string, string> Properties { get; set; } = new();
}
/// <summary>
/// Represents a refresh token document.
/// </summary>
public sealed class AuthorityRefreshTokenDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TokenId { get; set; } = string.Empty;
public string? SubjectId { get; set; }
public string? ClientId { get; set; }
public string? Handle { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset? ConsumedAt { get; set; }
public string? Payload { get; set; }
}
/// <summary>
/// Represents an airgap audit document.
/// </summary>
public sealed class AuthorityAirgapAuditDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string EventType { get; set; } = string.Empty;
public string? OperatorId { get; set; }
public string? ComponentId { get; set; }
public string Outcome { get; set; } = string.Empty;
public string? Reason { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public List<AuthorityAirgapAuditPropertyDocument> Properties { get; set; } = new();
}
/// <summary>
/// Represents a property in an airgap audit document.
/// </summary>
public sealed class AuthorityAirgapAuditPropertyDocument
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
/// <summary>
/// Represents a certificate binding for client authentication.
/// </summary>
public sealed class AuthorityClientCertificateBinding
{
public string? Thumbprint { get; set; }
public string? SerialNumber { get; set; }
public string? Subject { get; set; }
public string? Issuer { get; set; }
public List<string> SubjectAlternativeNames { get; set; } = new();
public DateTimeOffset? NotBefore { get; set; }
public DateTimeOffset? NotAfter { get; set; }
public string? Label { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,153 @@
using System.Linq.Expressions;
namespace MongoDB.Driver;
/// <summary>
/// Compatibility shim for MongoDB IMongoCollection interface.
/// In PostgreSQL mode, this provides an in-memory implementation.
/// </summary>
public interface IMongoCollection<TDocument>
{
IMongoDatabase Database { get; }
string CollectionNamespace { get; }
Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TDocument>> FindAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
Task InsertOneAsync(TDocument document, CancellationToken cancellationToken = default);
Task ReplaceOneAsync(Expression<Func<TDocument, bool>> filter, TDocument replacement, bool isUpsert = false, CancellationToken cancellationToken = default);
Task DeleteOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
Task<long> CountDocumentsAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
}
/// <summary>
/// Compatibility shim for MongoDB IMongoDatabase interface.
/// </summary>
public interface IMongoDatabase
{
string DatabaseNamespace { get; }
IMongoCollection<TDocument> GetCollection<TDocument>(string name);
}
/// <summary>
/// Compatibility shim for MongoDB IMongoClient interface.
/// </summary>
public interface IMongoClient
{
IMongoDatabase GetDatabase(string name);
}
/// <summary>
/// In-memory implementation of IMongoCollection for compatibility.
/// </summary>
public class InMemoryMongoCollection<TDocument> : IMongoCollection<TDocument>
{
private readonly List<TDocument> _documents = new();
private readonly IMongoDatabase _database;
private readonly string _name;
public InMemoryMongoCollection(IMongoDatabase database, string name)
{
_database = database;
_name = name;
}
public IMongoDatabase Database => _database;
public string CollectionNamespace => _name;
public Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
{
var compiled = filter.Compile();
var result = _documents.FirstOrDefault(compiled);
return Task.FromResult(result);
}
public Task<IReadOnlyList<TDocument>> FindAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
{
var compiled = filter.Compile();
IReadOnlyList<TDocument> result = _documents.Where(compiled).ToList();
return Task.FromResult(result);
}
public Task InsertOneAsync(TDocument document, CancellationToken cancellationToken = default)
{
_documents.Add(document);
return Task.CompletedTask;
}
public Task ReplaceOneAsync(Expression<Func<TDocument, bool>> filter, TDocument replacement, bool isUpsert = false, CancellationToken cancellationToken = default)
{
var compiled = filter.Compile();
var index = _documents.FindIndex(d => compiled(d));
if (index >= 0)
{
_documents[index] = replacement;
}
else if (isUpsert)
{
_documents.Add(replacement);
}
return Task.CompletedTask;
}
public Task DeleteOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
{
var compiled = filter.Compile();
var item = _documents.FirstOrDefault(compiled);
if (item != null)
{
_documents.Remove(item);
}
return Task.CompletedTask;
}
public Task<long> CountDocumentsAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
{
var compiled = filter.Compile();
var count = _documents.Count(compiled);
return Task.FromResult((long)count);
}
}
/// <summary>
/// In-memory implementation of IMongoDatabase for compatibility.
/// </summary>
public class InMemoryMongoDatabase : IMongoDatabase
{
private readonly Dictionary<string, object> _collections = new();
private readonly string _name;
public InMemoryMongoDatabase(string name)
{
_name = name;
}
public string DatabaseNamespace => _name;
public IMongoCollection<TDocument> GetCollection<TDocument>(string name)
{
if (!_collections.TryGetValue(name, out var collection))
{
collection = new InMemoryMongoCollection<TDocument>(this, name);
_collections[name] = collection;
}
return (IMongoCollection<TDocument>)collection;
}
}
/// <summary>
/// In-memory implementation of IMongoClient for compatibility.
/// </summary>
public class InMemoryMongoClient : IMongoClient
{
private readonly Dictionary<string, IMongoDatabase> _databases = new();
public IMongoDatabase GetDatabase(string name)
{
if (!_databases.TryGetValue(name, out var database))
{
database = new InMemoryMongoDatabase(name);
_databases[name] = database;
}
return database;
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
/// <summary>
/// Compatibility shim storage options. In PostgreSQL mode, these are largely unused.
/// </summary>
public sealed class AuthorityMongoStorageOptions
{
public string ConnectionString { get; set; } = string.Empty;
public string DatabaseName { get; set; } = "authority";
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Extension methods for configuring Authority MongoDB compatibility storage services.
/// In PostgreSQL mode, this registers in-memory implementations for the Mongo interfaces.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Authority MongoDB compatibility storage services (in-memory implementations).
/// For production PostgreSQL storage, use AddAuthorityPostgresStorage from StellaOps.Authority.Storage.Postgres.
/// </summary>
public static IServiceCollection AddAuthorityMongoStorage(
this IServiceCollection services,
Action<AuthorityMongoStorageOptions> configureOptions)
{
var options = new AuthorityMongoStorageOptions();
configureOptions(options);
services.AddSingleton(options);
RegisterMongoCompatServices(services, options);
return services;
}
private static void RegisterMongoCompatServices(IServiceCollection services, AuthorityMongoStorageOptions options)
{
// Register the initializer (no-op for Postgres mode)
services.AddSingleton<AuthorityMongoInitializer>();
// Register null session accessor
services.AddSingleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>();
// Register in-memory MongoDB shims for compatibility
var inMemoryClient = new InMemoryMongoClient();
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
services.AddSingleton<IMongoClient>(inMemoryClient);
services.AddSingleton<IMongoDatabase>(inMemoryDatabase);
// Register in-memory store implementations
// These should be replaced by Postgres-backed implementations over time
services.AddSingleton<IAuthorityBootstrapInviteStore, InMemoryBootstrapInviteStore>();
services.AddSingleton<IAuthorityServiceAccountStore, InMemoryServiceAccountStore>();
services.AddSingleton<IAuthorityClientStore, InMemoryClientStore>();
services.AddSingleton<IAuthorityRevocationStore, InMemoryRevocationStore>();
services.AddSingleton<IAuthorityLoginAttemptStore, InMemoryLoginAttemptStore>();
services.AddSingleton<IAuthorityTokenStore, InMemoryTokenStore>();
services.AddSingleton<IAuthorityRefreshTokenStore, InMemoryRefreshTokenStore>();
services.AddSingleton<IAuthorityAirgapAuditStore, InMemoryAirgapAuditStore>();
}
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Authority.Storage.Mongo.Initialization;
/// <summary>
/// Compatibility shim for MongoDB initializer. In PostgreSQL mode, this is a no-op.
/// The actual initialization is handled by PostgreSQL migrations.
/// </summary>
public sealed class AuthorityMongoInitializer
{
/// <summary>
/// Initializes the database. In PostgreSQL mode, this is a no-op as migrations handle setup.
/// </summary>
public Task InitialiseAsync(object database, CancellationToken cancellationToken)
{
// No-op for PostgreSQL mode - migrations handle schema setup
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Authority.Storage.Mongo.Sessions;
/// <summary>
/// Compatibility shim for MongoDB session handle. In PostgreSQL mode, this is unused.
/// </summary>
public interface IClientSessionHandle : IDisposable
{
}
/// <summary>
/// Compatibility shim for MongoDB session accessor. In PostgreSQL mode, this returns null.
/// </summary>
public interface IAuthorityMongoSessionAccessor
{
IClientSessionHandle? CurrentSession { get; }
}
/// <summary>
/// In-memory implementation that always returns null session.
/// </summary>
public sealed class NullAuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public IClientSessionHandle? CurrentSession => null;
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Storage.Mongo</RootNamespace>
<Description>MongoDB compatibility shim for Authority storage - provides in-memory implementations for Mongo interfaces while PostgreSQL migration is in progress</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,90 @@
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Stores;
/// <summary>
/// Store interface for bootstrap invites.
/// </summary>
public interface IAuthorityBootstrapInviteStore
{
ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
/// <summary>
/// Store interface for service accounts.
/// </summary>
public interface IAuthorityServiceAccountStore
{
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
/// <summary>
/// Store interface for clients.
/// </summary>
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);
}
/// <summary>
/// Store interface for revocations.
/// </summary>
public interface IAuthorityRevocationStore
{
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
/// <summary>
/// Store interface for login attempts.
/// </summary>
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);
}
/// <summary>
/// Store interface for tokens.
/// </summary>
public interface IAuthorityTokenStore
{
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
/// <summary>
/// Store interface for refresh tokens.
/// </summary>
public interface IAuthorityRefreshTokenStore
{
ValueTask<AuthorityRefreshTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityRefreshTokenDocument?> FindByHandleAsync(string handle, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ConsumeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
/// <summary>
/// Store interface for airgap audit entries.
/// </summary>
public interface IAuthorityAirgapAuditStore
{
ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityAirgapAuditDocument>> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -0,0 +1,294 @@
using System.Collections.Concurrent;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Stores;
/// <summary>
/// In-memory implementation of bootstrap invite store for development/testing.
/// </summary>
public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStore
{
private readonly ConcurrentDictionary<string, AuthorityBootstrapInviteDocument> _invites = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_invites.TryGetValue(token, out var doc);
return ValueTask.FromResult(doc);
}
public ValueTask InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_invites[document.Token] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (_invites.TryGetValue(token, out var doc))
{
doc.Consumed = true;
return ValueTask.FromResult(true);
}
return ValueTask.FromResult(false);
}
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var expired = _invites.Values
.Where(i => !i.Consumed && i.ExpiresAt <= asOf)
.ToList();
foreach (var item in expired)
{
_invites.TryRemove(item.Token, out _);
}
return ValueTask.FromResult<IReadOnlyList<AuthorityBootstrapInviteDocument>>(expired);
}
}
/// <summary>
/// In-memory implementation of service account store for development/testing.
/// </summary>
public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
{
private readonly ConcurrentDictionary<string, AuthorityServiceAccountDocument> _accounts = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_accounts.TryGetValue(accountId, out var doc);
return ValueTask.FromResult(doc);
}
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
var results = tenant is null
? _accounts.Values.ToList()
: _accounts.Values.Where(a => a.Tenant == tenant).ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
}
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
document.UpdatedAt = DateTimeOffset.UtcNow;
_accounts[document.AccountId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
return ValueTask.FromResult(_accounts.TryRemove(accountId, out _));
}
}
/// <summary>
/// In-memory implementation of client store for development/testing.
/// </summary>
public sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly ConcurrentDictionary<string, AuthorityClientDocument> _clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_clients.TryGetValue(clientId, out var doc);
return ValueTask.FromResult(doc);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
document.UpdatedAt = DateTimeOffset.UtcNow;
_clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
return ValueTask.FromResult(_clients.TryRemove(clientId, out _));
}
}
/// <summary>
/// In-memory implementation of revocation store for development/testing.
/// </summary>
public sealed class InMemoryRevocationStore : IAuthorityRevocationStore
{
private readonly ConcurrentDictionary<string, AuthorityRevocationDocument> _revocations = new(StringComparer.OrdinalIgnoreCase);
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var key = $"{document.Category}:{document.RevocationId}";
_revocations[key] = document;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var active = _revocations.Values
.Where(r => r.ExpiresAt is null || r.ExpiresAt > asOf)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(active);
}
public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var key = $"{category}:{revocationId}";
_revocations.TryRemove(key, out _);
return ValueTask.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of login attempt store for development/testing.
/// </summary>
public sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly ConcurrentBag<AuthorityLoginAttemptDocument> _attempts = new();
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_attempts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var results = _attempts
.Where(a => a.SubjectId == subjectId)
.OrderByDescending(a => a.OccurredAt)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(results);
}
}
/// <summary>
/// In-memory implementation of token store for development/testing.
/// </summary>
public sealed class InMemoryTokenStore : IAuthorityTokenStore
{
private readonly ConcurrentDictionary<string, AuthorityTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_tokens.TryGetValue(tokenId, out var doc);
return ValueTask.FromResult(doc);
}
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var doc = _tokens.Values.FirstOrDefault(t => t.ReferenceId == referenceId);
return ValueTask.FromResult(doc);
}
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var results = _tokens.Values
.Where(t => t.SubjectId == subjectId)
.OrderByDescending(t => t.CreatedAt)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(results);
}
public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_tokens[document.TokenId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
return ValueTask.FromResult(_tokens.TryRemove(tokenId, out _));
}
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var toRemove = _tokens.Where(kv => kv.Value.SubjectId == subjectId).Select(kv => kv.Key).ToList();
foreach (var key in toRemove)
{
_tokens.TryRemove(key, out _);
}
return ValueTask.FromResult(toRemove.Count);
}
public ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var toRemove = _tokens.Where(kv => kv.Value.ClientId == clientId).Select(kv => kv.Key).ToList();
foreach (var key in toRemove)
{
_tokens.TryRemove(key, out _);
}
return ValueTask.FromResult(toRemove.Count);
}
}
/// <summary>
/// In-memory implementation of refresh token store for development/testing.
/// </summary>
public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
{
private readonly ConcurrentDictionary<string, AuthorityRefreshTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityRefreshTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_tokens.TryGetValue(tokenId, out var doc);
return ValueTask.FromResult(doc);
}
public ValueTask<AuthorityRefreshTokenDocument?> FindByHandleAsync(string handle, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var doc = _tokens.Values.FirstOrDefault(t => t.Handle == handle);
return ValueTask.FromResult(doc);
}
public ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_tokens[document.TokenId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> ConsumeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (_tokens.TryGetValue(tokenId, out var doc))
{
doc.ConsumedAt = DateTimeOffset.UtcNow;
return ValueTask.FromResult(true);
}
return ValueTask.FromResult(false);
}
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var toRemove = _tokens.Where(kv => kv.Value.SubjectId == subjectId).Select(kv => kv.Key).ToList();
foreach (var key in toRemove)
{
_tokens.TryRemove(key, out _);
}
return ValueTask.FromResult(toRemove.Count);
}
}
/// <summary>
/// In-memory implementation of airgap audit store for development/testing.
/// </summary>
public sealed class InMemoryAirgapAuditStore : IAuthorityAirgapAuditStore
{
private readonly ConcurrentBag<AuthorityAirgapAuditDocument> _entries = new();
public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_entries.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityAirgapAuditDocument>> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var results = _entries
.OrderByDescending(e => e.OccurredAt)
.Skip(offset)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityAirgapAuditDocument>>(results);
}
}

View File

@@ -12,7 +12,7 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<!-- MongoDB.Driver removed - using Mongo compatibility shim via Authority project reference -->
</ItemGroup>
<ItemGroup>
<Compile Include="../../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />

View File

@@ -29,8 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{67C85AC6-1670-4A0D-A81F-6015574F46C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{17829125-C0F5-47E6-A16C-EC142BD58220}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{9B4BA030-C979-4191-8B4F-7E2AD9F88A94}"
@@ -41,8 +39,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard.Tests", "StellaOps.Authority.Plugin.Standard.Tests\StellaOps.Authority.Plugin.Standard.Tests.csproj", "{0C222CD9-96B1-4152-BD29-65FFAE27C880}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{977FD870-91B5-44BA-944B-496B2C68DAA0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions.Tests", "StellaOps.Auth.Abstractions.Tests\StellaOps.Auth.Abstractions.Tests.csproj", "{4A5D29B8-959A-4EAC-A827-979CD058EC16}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration.Tests", "StellaOps.Auth.ServerIntegration.Tests\StellaOps.Auth.ServerIntegration.Tests.csproj", "{CB7FD547-1EC7-4A6F-87FE-F73003512AFE}"
@@ -227,18 +223,6 @@ Global
{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}.Release|x64.Build.0 = Release|Any CPU
{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}.Release|x86.ActiveCfg = Release|Any CPU
{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}.Release|x86.Build.0 = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x64.ActiveCfg = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x64.Build.0 = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x86.ActiveCfg = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x86.Build.0 = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|Any CPU.Build.0 = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x64.ActiveCfg = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x64.Build.0 = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x86.ActiveCfg = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x86.Build.0 = Release|Any CPU
{17829125-C0F5-47E6-A16C-EC142BD58220}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17829125-C0F5-47E6-A16C-EC142BD58220}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17829125-C0F5-47E6-A16C-EC142BD58220}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -299,18 +283,6 @@ Global
{0C222CD9-96B1-4152-BD29-65FFAE27C880}.Release|x64.Build.0 = Release|Any CPU
{0C222CD9-96B1-4152-BD29-65FFAE27C880}.Release|x86.ActiveCfg = Release|Any CPU
{0C222CD9-96B1-4152-BD29-65FFAE27C880}.Release|x86.Build.0 = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x64.ActiveCfg = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x64.Build.0 = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x86.ActiveCfg = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x86.Build.0 = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|Any CPU.Build.0 = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x64.ActiveCfg = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x64.Build.0 = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x86.ActiveCfg = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x86.Build.0 = Release|Any CPU
{4A5D29B8-959A-4EAC-A827-979CD058EC16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A5D29B8-959A-4EAC-A827-979CD058EC16}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A5D29B8-959A-4EAC-A827-979CD058EC16}.Debug|x64.ActiveCfg = Debug|Any CPU

View File

@@ -19,7 +19,7 @@ using Microsoft.Net.Http.Headers;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
// MongoDB.Driver removed - using PostgreSQL storage with Mongo compatibility shim
using Serilog;
using Serilog.Events;
using StellaOps.Authority;
@@ -399,9 +399,9 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
var app = builder.Build();
// Initialize storage (Mongo shim delegates to PostgreSQL migrations)
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
var mongoDatabase = app.Services.GetRequiredService<IMongoDatabase>();
await mongoInitializer.InitialiseAsync(mongoDatabase, CancellationToken.None);
await mongoInitializer.InitialiseAsync(null!, CancellationToken.None);
var serviceAccountStore = app.Services.GetRequiredService<IAuthorityServiceAccountStore>();
if (authorityOptions.Delegation.ServiceAccounts.Count > 0)

View File

@@ -23,6 +23,7 @@
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />

View File

@@ -1,30 +1,30 @@
using System.Net;
using MongoContracts = StellaOps.Concelier.Storage.Mongo;
using System.Net;
using StellaOps.Concelier.Storage.Contracts;
namespace StellaOps.Concelier.Connector.Common.Fetch;
/// <summary>
/// Outcome of fetching a raw document from an upstream source.
/// </summary>
public sealed record SourceFetchResult
{
private SourceFetchResult(HttpStatusCode statusCode, MongoContracts.DocumentRecord? document, bool notModified)
{
StatusCode = statusCode;
Document = document;
IsNotModified = notModified;
}
public sealed record SourceFetchResult
{
private SourceFetchResult(HttpStatusCode statusCode, StorageDocument? document, bool notModified)
{
StatusCode = statusCode;
Document = document;
IsNotModified = notModified;
}
public HttpStatusCode StatusCode { get; }
public MongoContracts.DocumentRecord? Document { get; }
public StorageDocument? Document { get; }
public bool IsSuccess => Document is not null;
public bool IsNotModified { get; }
public static SourceFetchResult Success(MongoContracts.DocumentRecord document, HttpStatusCode statusCode)
=> new(statusCode, document, notModified: false);
public static SourceFetchResult Success(StorageDocument document, HttpStatusCode statusCode)
=> new(statusCode, document, notModified: false);
public static SourceFetchResult NotModified(HttpStatusCode statusCode)
=> new(statusCode, null, notModified: true);

View File

@@ -6,11 +6,11 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="AngleSharp" Version="1.1.1" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
<PackageReference Include="NuGet.Versioning" Version="6.9.1" />
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="AngleSharp" Version="1.2.0" />
<PackageReference Include="PdfPig" Version="0.1.12" />
<PackageReference Include="NuGet.Versioning" Version="6.13.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.1" />
<PackageReference Include="AngleSharp" Version="1.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.1" />
<PackageReference Include="AngleSharp" Version="1.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.1" />
<PackageReference Include="AngleSharp" Version="1.2.0" />
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
</ItemGroup>

View File

@@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="9.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -17,6 +17,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="NuGet.Versioning" Version="6.9.1" />
<PackageReference Include="NuGet.Versioning" Version="6.13.2" />
</ItemGroup>
</Project>

View File

@@ -7,8 +7,8 @@
<IsTestProject>false</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageReference Include="xunit" Version="2.9.2">
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="xunit" Version="2.9.3">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core.Evidence;
namespace StellaOps.Excititor.WebService.Contracts;
public sealed record EvidenceManifestResponse(
[property: JsonPropertyName("manifest")] VexLockerManifest Manifest,
[property: JsonPropertyName("attestationId")] string AttestationId,
[property: JsonPropertyName("dsseEnvelope")] string DsseEnvelope,
[property: JsonPropertyName("dsseEnvelopeHash")] string DsseEnvelopeHash,
[property: JsonPropertyName("itemCount")] int ItemCount,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt);
public sealed record EvidenceChunkListResponse(
[property: JsonPropertyName("chunks")] IReadOnlyList<VexEvidenceChunkResponse> Chunks,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("truncated")] bool Truncated,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt);

View File

@@ -10,18 +10,45 @@ public sealed record GraphOverlaysResponse(
[property: JsonPropertyName("cacheAgeMs")] long? CacheAgeMs);
public sealed record GraphOverlayItem(
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("purl")] string Purl,
[property: JsonPropertyName("summary")] GraphOverlaySummary Summary,
[property: JsonPropertyName("latestModifiedAt")] DateTimeOffset? LatestModifiedAt,
[property: JsonPropertyName("justifications")] IReadOnlyList<string> Justifications,
[property: JsonPropertyName("provenance")] GraphOverlayProvenance Provenance);
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justifications")] IReadOnlyList<GraphOverlayJustification> Justifications,
[property: JsonPropertyName("conflicts")] IReadOnlyList<GraphOverlayConflict> Conflicts,
[property: JsonPropertyName("observations")] IReadOnlyList<GraphOverlayObservation> Observations,
[property: JsonPropertyName("provenance")] GraphOverlayProvenance Provenance,
[property: JsonPropertyName("cache")] GraphOverlayCache? Cache);
public sealed record GraphOverlaySummary(
[property: JsonPropertyName("open")] int Open,
[property: JsonPropertyName("not_affected")] int NotAffected,
[property: JsonPropertyName("under_investigation")] int UnderInvestigation,
[property: JsonPropertyName("no_statement")] int NoStatement);
public sealed record GraphOverlayJustification(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("evidence")] IReadOnlyList<string>? Evidence,
[property: JsonPropertyName("weight")] double? Weight);
public sealed record GraphOverlayConflict(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("values")] IReadOnlyList<string> Values,
[property: JsonPropertyName("sourceIds")] IReadOnlyList<string>? SourceIds);
public sealed record GraphOverlayObservation(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("fetchedAt")] DateTimeOffset FetchedAt);
public sealed record GraphOverlayProvenance(
[property: JsonPropertyName("sources")] IReadOnlyList<string> Sources,
[property: JsonPropertyName("lastEvidenceHash")] string? LastEvidenceHash);
[property: JsonPropertyName("linksetId")] string LinksetId,
[property: JsonPropertyName("linksetHash")] string LinksetHash,
[property: JsonPropertyName("observationHashes")] IReadOnlyList<string> ObservationHashes,
[property: JsonPropertyName("policyHash")] string? PolicyHash,
[property: JsonPropertyName("sbomContextHash")] string? SbomContextHash,
[property: JsonPropertyName("planCacheKey")] string? PlanCacheKey);
public sealed record GraphOverlayCache(
[property: JsonPropertyName("cached")] bool Cached,
[property: JsonPropertyName("cachedAt")] DateTimeOffset? CachedAt,
[property: JsonPropertyName("ttlSeconds")] int? TtlSeconds);

View File

@@ -15,3 +15,9 @@ public sealed record GraphStatusItem(
[property: JsonPropertyName("latestModifiedAt")] DateTimeOffset? LatestModifiedAt,
[property: JsonPropertyName("sources")] IReadOnlyList<string> Sources,
[property: JsonPropertyName("lastEvidenceHash")] string? LastEvidenceHash);
public sealed record GraphOverlaySummary(
[property: JsonPropertyName("open")] int Open,
[property: JsonPropertyName("not_affected")] int NotAffected,
[property: JsonPropertyName("under_investigation")] int UnderInvestigation,
[property: JsonPropertyName("no_statement")] int NoStatement);

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Services;
using static Program;
namespace StellaOps.Excititor.WebService.Endpoints;

View File

@@ -2,23 +2,38 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
using static Program;
using StellaOps.Excititor.WebService.Telemetry;
using System.Collections.Immutable;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Evidence API endpoints (temporarily disabled while Mongo/BSON storage is removed).
/// Evidence API endpoints (manifest + DSSE attestation + evidence chunks).
/// </summary>
public static class EvidenceEndpoints
{
public static void MapEvidenceEndpoints(this WebApplication app)
{
// GET /evidence/vex/list
app.MapGet("/evidence/vex/list", (
app.MapGet("/evidence/vex/list", async (
HttpContext context,
[FromQuery(Name = "vulnerabilityId")] string[] vulnerabilityIds,
[FromQuery(Name = "productKey")] string[] productKeys,
[FromQuery] string? since,
[FromQuery] int? limit,
IVexClaimStore claimStore,
IVexEvidenceLockerService lockerService,
IVexEvidenceAttestor attestor,
IOptions<VexStorageOptions> storageOptions,
ChunkTelemetry chunkTelemetry) =>
ChunkTelemetry chunkTelemetry,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
@@ -31,18 +46,76 @@ public static class EvidenceEndpoints
return tenantError;
}
chunkTelemetry.RecordIngested(tenant, null, "unavailable", "storage-migration", 0, 0, 0);
return Results.Problem(
detail: "Evidence exports are temporarily unavailable during Postgres migration (Mongo/BSON removed).",
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Service unavailable");
var parsedSince = ParseSinceTimestamp(new Microsoft.Extensions.Primitives.StringValues(since));
var max = Math.Clamp(limit ?? 500, 1, 1000);
var pairs = NormalizeValues(vulnerabilityIds).SelectMany(v =>
NormalizeValues(productKeys).Select(p => (Vuln: v, Product: p))).ToList();
if (pairs.Count == 0)
{
return Results.BadRequest("At least one vulnerabilityId and productKey are required.");
}
var claims = new List<VexClaim>();
foreach (var pair in pairs)
{
var found = await claimStore.FindAsync(pair.Vuln, pair.Product, parsedSince, cancellationToken).ConfigureAwait(false);
claims.AddRange(found);
}
claims = claims
.OrderBy(c => c.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
.ThenBy(c => c.Product.Key, StringComparer.OrdinalIgnoreCase)
.ThenByDescending(c => c.LastSeen)
.Take(max)
.ToList();
if (claims.Count == 0)
{
return Results.NotFound("No claims available for the requested filters.");
}
var items = claims.Select(claim =>
new VexEvidenceSnapshotItem(
observationId: FormattableString.Invariant($"{claim.ProviderId}:{claim.Document.Digest}"),
providerId: claim.ProviderId,
contentHash: claim.Document.Digest,
linksetId: FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"),
dsseEnvelopeHash: null,
provenance: new VexEvidenceProvenance("ingest")))
.ToList();
var now = timeProvider.GetUtcNow();
var manifest = lockerService.BuildManifest(tenant, items, timestamp: now, sequence: 1, isSealed: false);
var attestation = await attestor.AttestManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
chunkTelemetry.RecordIngested(tenant, null, "available", "locker-manifest", claims.Count, 0, 0);
var response = new EvidenceManifestResponse(
attestation.SignedManifest,
attestation.AttestationId,
attestation.DsseEnvelopeJson,
attestation.DsseEnvelopeHash,
attestation.SignedManifest.Items.Length,
attestation.AttestedAt);
return Results.Ok(response);
}).WithName("ListVexEvidence");
// GET /evidence/vex/{bundleId}
app.MapGet("/evidence/vex/{bundleId}", (
app.MapGet("/evidence/vex/{bundleId}", async (
HttpContext context,
string bundleId,
IOptions<VexStorageOptions> storageOptions) =>
[FromQuery(Name = "vulnerabilityId")] string[] vulnerabilityIds,
[FromQuery(Name = "productKey")] string[] productKeys,
[FromQuery] string? since,
[FromQuery] int? limit,
IVexClaimStore claimStore,
IVexEvidenceLockerService lockerService,
IVexEvidenceAttestor attestor,
IOptions<VexStorageOptions> storageOptions,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
@@ -50,7 +123,7 @@ public static class EvidenceEndpoints
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError))
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
@@ -63,17 +136,77 @@ public static class EvidenceEndpoints
title: "Validation error");
}
return Results.Problem(
detail: "Evidence bundles are temporarily unavailable during Postgres migration (Mongo/BSON removed).",
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Service unavailable");
var parsedSince = ParseSinceTimestamp(new Microsoft.Extensions.Primitives.StringValues(since));
var max = Math.Clamp(limit ?? 500, 1, 1000);
var pairs = NormalizeValues(vulnerabilityIds).SelectMany(v =>
NormalizeValues(productKeys).Select(p => (Vuln: v, Product: p))).ToList();
if (pairs.Count == 0)
{
return Results.BadRequest("At least one vulnerabilityId and productKey are required.");
}
var claims = new List<VexClaim>();
foreach (var pair in pairs)
{
var found = await claimStore.FindAsync(pair.Vuln, pair.Product, parsedSince, cancellationToken).ConfigureAwait(false);
claims.AddRange(found);
}
claims = claims
.OrderBy(c => c.VulnerabilityId, StringComparer.OrdinalIgnoreCase)
.ThenBy(c => c.Product.Key, StringComparer.OrdinalIgnoreCase)
.ThenByDescending(c => c.LastSeen)
.Take(max)
.ToList();
if (claims.Count == 0)
{
return Results.NotFound("No claims available for the requested filters.");
}
var items = claims.Select(claim =>
new VexEvidenceSnapshotItem(
observationId: FormattableString.Invariant($"{claim.ProviderId}:{claim.Document.Digest}"),
providerId: claim.ProviderId,
contentHash: claim.Document.Digest,
linksetId: FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"),
dsseEnvelopeHash: null,
provenance: new VexEvidenceProvenance("ingest")))
.ToList();
var now = timeProvider.GetUtcNow();
var manifest = lockerService.BuildManifest(tenant, items, timestamp: now, sequence: 1, isSealed: false);
if (!string.Equals(manifest.ManifestId, bundleId, StringComparison.OrdinalIgnoreCase))
{
return Results.NotFound($"Requested bundleId '{bundleId}' not found for current filters.");
}
var attestation = await attestor.AttestManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
var response = new EvidenceManifestResponse(
attestation.SignedManifest,
attestation.AttestationId,
attestation.DsseEnvelopeJson,
attestation.DsseEnvelopeHash,
attestation.SignedManifest.Items.Length,
attestation.AttestedAt);
return Results.Ok(response);
}).WithName("GetVexEvidenceBundle");
// GET /v1/vex/evidence/chunks
app.MapGet("/v1/vex/evidence/chunks", (
app.MapGet("/v1/vex/evidence/chunks", async (
HttpContext context,
[FromQuery] string vulnerabilityId,
[FromQuery] string productKey,
[FromQuery(Name = "providerId")] string[] providerIds,
[FromQuery] string[] status,
[FromQuery] string? since,
[FromQuery] int? limit,
IOptions<VexStorageOptions> storageOptions,
ChunkTelemetry chunkTelemetry) =>
IVexEvidenceChunkService chunkService,
ChunkTelemetry chunkTelemetry,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
@@ -86,11 +219,37 @@ public static class EvidenceEndpoints
return tenantError;
}
chunkTelemetry.RecordIngested(tenant, null, "unavailable", "storage-migration", 0, 0, 0);
return Results.Problem(
detail: "Evidence chunk streaming is temporarily unavailable during Postgres migration (Mongo/BSON removed).",
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Service unavailable");
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest("vulnerabilityId and productKey are required.");
}
var parsedSince = ParseSinceTimestamp(new Microsoft.Extensions.Primitives.StringValues(since));
var providers = providerIds?.Length > 0
? providerIds.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase)
: ImmutableHashSet<string>.Empty;
var statuses = status?.Length > 0
? status
.Select(s => Enum.TryParse<VexClaimStatus>(s, true, out var parsed) ? parsed : (VexClaimStatus?)null)
.Where(s => s is not null)
.Select(s => s!.Value)
.ToImmutableHashSet()
: ImmutableHashSet<VexClaimStatus>.Empty;
var req = new VexEvidenceChunkRequest(
tenant,
vulnerabilityId,
productKey,
providers,
statuses,
parsedSince,
Math.Clamp(limit ?? 200, 1, 1000));
var result = await chunkService.QueryAsync(req, cancellationToken).ConfigureAwait(false);
chunkTelemetry.RecordIngested(tenant, null, "available", "locker-chunks", result.TotalCount, 0, 0);
return Results.Ok(new EvidenceChunkListResponse(result.Chunks, result.TotalCount, result.Truncated, result.GeneratedAtUtc));
}).WithName("GetVexEvidenceChunks");
}
}

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
@@ -71,9 +72,9 @@ internal static class MirrorEndpoints
string domainId,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
MirrorRateLimiter rateLimiter,
IVexExportStore exportStore,
TimeProvider timeProvider,
[FromServices] MirrorRateLimiter rateLimiter,
[FromServices] IVexExportStore exportStore,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))
@@ -162,9 +163,9 @@ internal static class MirrorEndpoints
string exportKey,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
MirrorRateLimiter rateLimiter,
IVexExportStore exportStore,
TimeProvider timeProvider,
[FromServices] MirrorRateLimiter rateLimiter,
[FromServices] IVexExportStore exportStore,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))
@@ -215,9 +216,9 @@ internal static class MirrorEndpoints
string exportKey,
HttpContext httpContext,
IOptions<MirrorDistributionOptions> options,
MirrorRateLimiter rateLimiter,
IVexExportStore exportStore,
IEnumerable<IVexArtifactStore> artifactStores,
[FromServices] MirrorRateLimiter rateLimiter,
[FromServices] IVexExportStore exportStore,
[FromServices] IEnumerable<IVexArtifactStore> artifactStores,
CancellationToken cancellationToken)
{
if (!TryFindDomain(options.Value, domainId, out var domain))

View File

@@ -9,8 +9,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Canonicalization;
using StellaOps.Excititor.Core.Orchestration;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
@@ -34,7 +32,7 @@ public static class PolicyEndpoints
HttpContext context,
[FromBody] PolicyVexLookupRequest request,
IOptions<VexStorageOptions> storageOptions,
[FromServices] IVexClaimStore claimStore,
[FromServices] IGraphOverlayStore overlayStore,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
@@ -45,7 +43,7 @@ public static class PolicyEndpoints
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out _, out var tenantError))
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError!;
}
@@ -56,24 +54,19 @@ public static class PolicyEndpoints
return Results.BadRequest(new { error = new { code = "ERR_REQUEST", message = "advisory_keys or purls must be provided" } });
}
var canonicalizer = new VexAdvisoryKeyCanonicalizer();
var productCanonicalizer = new VexProductKeyCanonicalizer();
var canonicalAdvisories = request.AdvisoryKeys
var advisories = request.AdvisoryKeys
.Where(a => !string.IsNullOrWhiteSpace(a))
.Select(a => canonicalizer.Canonicalize(a.Trim()))
.Select(a => a.Trim())
.ToList();
var canonicalProducts = request.Purls
var purls = request.Purls
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => productCanonicalizer.Canonicalize(p.Trim(), purl: p.Trim()))
.Select(p => p.Trim())
.ToList();
// Map requested statuses/providers for filtering
var statusFilter = request.Statuses
.Select(s => Enum.TryParse<VexClaimStatus>(s, true, out var parsed) ? parsed : (VexClaimStatus?)null)
.Where(p => p.HasValue)
.Select(p => p!.Value)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim().ToLowerInvariant())
.ToImmutableHashSet();
var providerFilter = request.Providers
@@ -81,94 +74,96 @@ public static class PolicyEndpoints
.Select(p => p.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
var limit = Math.Clamp(request.Limit, 1, 500);
var now = timeProvider.GetUtcNow();
var overlays = await ResolveOverlaysAsync(overlayStore, tenant!, advisories, purls, request.Limit, cancellationToken).ConfigureAwait(false);
var results = new List<PolicyVexLookupItem>();
var totalStatements = 0;
var filtered = overlays
.Where(o => MatchesProvider(providerFilter, o))
.Where(o => MatchesStatus(statusFilter, o))
.OrderBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Purl, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Source, StringComparer.OrdinalIgnoreCase)
.Take(Math.Clamp(request.Limit, 1, 500))
.ToList();
// For each advisory key, fetch claims and filter by product/provider/status
foreach (var advisory in canonicalAdvisories)
{
var claims = await claimStore
.FindByVulnerabilityAsync(advisory.AdvisoryKey, limit, cancellationToken)
.ConfigureAwait(false);
var grouped = filtered
.GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.Select(group => new PolicyVexLookupItem(
group.Key,
new[] { group.Key },
group.Select(MapStatement).ToList()))
.ToList();
var filtered = claims
.Where(claim => MatchesProvider(providerFilter, claim))
.Where(claim => MatchesStatus(statusFilter, claim))
.Where(claim => MatchesProduct(canonicalProducts, claim))
.OrderByDescending(claim => claim.LastSeen)
.ThenBy(claim => claim.ProviderId, StringComparer.Ordinal)
.ThenBy(claim => claim.Product.Key, StringComparer.Ordinal)
.Take(limit)
.ToList();
totalStatements += filtered.Count;
var statements = filtered.Select(MapStatement).ToList();
var aliases = advisory.Aliases.ToList();
if (!aliases.Contains(advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase))
{
aliases.Add(advisory.AdvisoryKey);
}
results.Add(new PolicyVexLookupItem(
advisory.AdvisoryKey,
aliases,
statements));
}
var response = new PolicyVexLookupResponse(results, totalStatements, now);
var response = new PolicyVexLookupResponse(grouped, filtered.Count, timeProvider.GetUtcNow());
return Results.Ok(response);
}
private static bool MatchesProvider(ISet<string> providers, VexClaim claim)
=> providers.Count == 0 || providers.Contains(claim.ProviderId, StringComparer.OrdinalIgnoreCase);
private static bool MatchesStatus(ISet<VexClaimStatus> statuses, VexClaim claim)
=> statuses.Count == 0 || statuses.Contains(claim.Status);
private static bool MatchesProduct(IEnumerable<VexCanonicalProductKey> requestedProducts, VexClaim claim)
private static async Task<IReadOnlyList<GraphOverlayItem>> ResolveOverlaysAsync(
IGraphOverlayStore overlayStore,
string tenant,
IReadOnlyList<string> advisories,
IReadOnlyList<string> purls,
int limit,
CancellationToken cancellationToken)
{
if (!requestedProducts.Any())
if (purls.Count > 0)
{
return true;
var overlays = await overlayStore.FindByPurlsAsync(tenant, purls, cancellationToken).ConfigureAwait(false);
if (advisories.Count == 0)
{
return overlays;
}
return overlays.Where(o => advisories.Contains(o.AdvisoryId, StringComparer.OrdinalIgnoreCase)).ToList();
}
return requestedProducts.Any(product =>
string.Equals(product.ProductKey, claim.Product.Key, StringComparison.OrdinalIgnoreCase) ||
product.Links.Any(link => string.Equals(link.Identifier, claim.Product.Key, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrWhiteSpace(product.Purl) && string.Equals(product.Purl, claim.Product.Purl, StringComparison.OrdinalIgnoreCase)));
return await overlayStore.FindByAdvisoriesAsync(tenant, advisories, limit, cancellationToken).ConfigureAwait(false);
}
private static PolicyVexStatement MapStatement(VexClaim claim)
private static bool MatchesProvider(ISet<string> providers, GraphOverlayItem overlay)
=> providers.Count == 0 || providers.Contains(overlay.Source, StringComparer.OrdinalIgnoreCase);
private static bool MatchesStatus(ISet<string> statuses, GraphOverlayItem overlay)
=> statuses.Count == 0 || statuses.Contains(overlay.Status, StringComparer.OrdinalIgnoreCase);
private static PolicyVexStatement MapStatement(GraphOverlayItem overlay)
{
var observationId = $"{claim.ProviderId}:{claim.Document.Digest}";
var firstSeen = overlay.Observations.Count == 0
? overlay.GeneratedAt
: overlay.Observations.Min(o => o.FetchedAt);
var lastSeen = overlay.Observations.Count == 0
? overlay.GeneratedAt
: overlay.Observations.Max(o => o.FetchedAt);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["document_digest"] = claim.Document.Digest,
["document_uri"] = claim.Document.SourceUri.ToString()
["schemaVersion"] = overlay.SchemaVersion,
["linksetId"] = overlay.Provenance.LinksetId,
["linksetHash"] = overlay.Provenance.LinksetHash,
["source"] = overlay.Source
};
if (!string.IsNullOrWhiteSpace(claim.Document.Revision))
if (!string.IsNullOrWhiteSpace(overlay.Provenance.PlanCacheKey))
{
metadata["document_revision"] = claim.Document.Revision!;
metadata["planCacheKey"] = overlay.Provenance.PlanCacheKey!;
}
var justification = overlay.Justifications.FirstOrDefault();
var primaryObservation = overlay.Observations.FirstOrDefault();
return new PolicyVexStatement(
ObservationId: observationId,
ProviderId: claim.ProviderId,
Status: claim.Status.ToString(),
ProductKey: claim.Product.Key,
Purl: claim.Product.Purl,
Cpe: claim.Product.Cpe,
Version: claim.Product.Version,
Justification: claim.Justification?.ToString(),
Detail: claim.Detail,
FirstSeen: claim.FirstSeen,
LastSeen: claim.LastSeen,
Signature: claim.Document.Signature,
ObservationId: primaryObservation?.Id ?? $"{overlay.Source}:{overlay.AdvisoryId}",
ProviderId: overlay.Source,
Status: overlay.Status,
ProductKey: overlay.Purl,
Purl: overlay.Purl,
Cpe: null,
Version: null,
Justification: justification?.Kind,
Detail: justification?.Reason,
FirstSeen: firstSeen,
LastSeen: lastSeen,
Signature: null,
Metadata: metadata);
}

View File

@@ -9,6 +9,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Attestation;
@@ -33,7 +34,7 @@ internal static class ResolveEndpoint
VexResolveRequest request,
HttpContext httpContext,
IVexClaimStore claimStore,
IVexConsensusStore consensusStore,
[FromServices] IVexConsensusStore? consensusStore,
IVexProviderStore providerStore,
IVexPolicyProvider policyProvider,
TimeProvider timeProvider,
@@ -142,7 +143,10 @@ internal static class ResolveEndpoint
snapshot.Digest);
}
await consensusStore.SaveAsync(consensus, cancellationToken).ConfigureAwait(false);
if (consensusStore is not null)
{
await consensusStore.SaveAsync(consensus, cancellationToken).ConfigureAwait(false);
}
var payload = PreparePayload(consensus);
var contentSignature = await TrySignAsync(signer, payload, logger, cancellationToken).ConfigureAwait(false);

View File

@@ -1,27 +1,27 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Concelier.RawModels;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using RawModels = StellaOps.Concelier.RawModels;
namespace StellaOps.Excititor.WebService.Extensions;
internal static class VexRawDocumentMapper
{
public static VexRawDocument ToRawModel(VexRawRecord record, string defaultTenant)
public static RawModels.VexRawDocument ToRawModel(VexRawRecord record, string defaultTenant)
{
ArgumentNullException.ThrowIfNull(record);
var metadata = record.Metadata ?? ImmutableDictionary<string, string>.Empty;
var tenant = Get(metadata, "tenant", record.Tenant) ?? defaultTenant;
var source = new RawSourceMetadata(
var source = new RawModels.RawSourceMetadata(
Vendor: Get(metadata, "source.vendor", record.ProviderId) ?? record.ProviderId,
Connector: Get(metadata, "source.connector", record.ProviderId) ?? record.ProviderId,
ConnectorVersion: Get(metadata, "source.connector_version", "unknown") ?? "unknown",
Stream: Get(metadata, "source.stream", record.Format.ToString().ToLowerInvariant()));
var signature = new RawSignatureMetadata(
var signature = new RawModels.RawSignatureMetadata(
Present: string.Equals(Get(metadata, "signature.present"), "true", StringComparison.OrdinalIgnoreCase),
Format: Get(metadata, "signature.format"),
KeyId: Get(metadata, "signature.key_id"),
@@ -29,7 +29,7 @@ internal static class VexRawDocumentMapper
Certificate: Get(metadata, "signature.certificate"),
Digest: Get(metadata, "signature.digest"));
var upstream = new RawUpstreamMetadata(
var upstream = new RawModels.RawUpstreamMetadata(
UpstreamId: Get(metadata, "upstream.id", record.Digest) ?? record.Digest,
DocumentVersion: Get(metadata, "upstream.version"),
RetrievedAt: record.RetrievedAt,
@@ -37,20 +37,20 @@ internal static class VexRawDocumentMapper
Signature: signature,
Provenance: metadata);
var content = new RawContent(
var content = new RawModels.RawContent(
Format: record.Format.ToString().ToLowerInvariant(),
SpecVersion: Get(metadata, "content.spec_version"),
Raw: ParseJson(record.Content),
Encoding: Get(metadata, "content.encoding"));
return new VexRawDocument(
return new RawModels.VexRawDocument(
tenant,
source,
upstream,
content,
new RawLinkset(),
statements: null,
supersedes: record.SupersedesDigest);
new RawModels.RawLinkset(),
Statements: null,
Supersedes: record.SupersedesDigest);
}
private static string? Get(IReadOnlyDictionary<string, string> metadata, string key, string? fallback = null)

View File

@@ -11,10 +11,17 @@ namespace StellaOps.Excititor.WebService.Graph;
internal static class GraphOverlayFactory
{
public static IReadOnlyList<GraphOverlayItem> Build(
string tenant,
DateTimeOffset generatedAt,
IReadOnlyList<string> orderedPurls,
IReadOnlyList<VexObservation> observations,
bool includeJustifications)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("tenant is required", nameof(tenant));
}
if (orderedPurls is null)
{
throw new ArgumentNullException(nameof(orderedPurls));
@@ -25,101 +32,215 @@ internal static class GraphOverlayFactory
throw new ArgumentNullException(nameof(observations));
}
var observationsByPurl = observations
.SelectMany(obs => obs.Linkset.Purls.Select(purl => (purl, obs)))
.GroupBy(tuple => tuple.purl, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Select(t => t.obs).ToImmutableArray(), StringComparer.OrdinalIgnoreCase);
var items = new List<GraphOverlayItem>(orderedPurls.Count);
foreach (var input in orderedPurls)
var purlOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < orderedPurls.Count; i++)
{
if (!observationsByPurl.TryGetValue(input, out var obsForPurl) || obsForPurl.Length == 0)
{
items.Add(new GraphOverlayItem(
Purl: input,
Summary: new GraphOverlaySummary(0, 0, 0, 0),
LatestModifiedAt: null,
Justifications: Array.Empty<string>(),
Provenance: new GraphOverlayProvenance(Array.Empty<string>(), null)));
continue;
}
var open = 0;
var notAffected = 0;
var underInvestigation = 0;
var noStatement = 0;
var justifications = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var sources = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
string? lastEvidenceHash = null;
DateTimeOffset? latestModifiedAt = null;
foreach (var obs in obsForPurl)
{
sources.Add(obs.ProviderId);
if (latestModifiedAt is null || obs.CreatedAt > latestModifiedAt.Value)
{
latestModifiedAt = obs.CreatedAt;
lastEvidenceHash = obs.Upstream.ContentHash;
}
var matchingStatements = obs.Statements
.Where(stmt => PurlMatches(stmt, input, obs.Linkset.Purls))
.ToArray();
if (matchingStatements.Length == 0)
{
noStatement++;
continue;
}
foreach (var stmt in matchingStatements)
{
switch (stmt.Status)
{
case VexClaimStatus.NotAffected:
notAffected++;
break;
case VexClaimStatus.UnderInvestigation:
underInvestigation++;
break;
default:
open++;
break;
}
if (includeJustifications && stmt.Justification is not null)
{
justifications.Add(stmt.Justification!.ToString()!);
}
}
}
items.Add(new GraphOverlayItem(
Purl: input,
Summary: new GraphOverlaySummary(open, notAffected, underInvestigation, noStatement),
LatestModifiedAt: latestModifiedAt,
Justifications: includeJustifications
? justifications.ToArray()
: Array.Empty<string>(),
Provenance: new GraphOverlayProvenance(sources.ToArray(), lastEvidenceHash)));
purlOrder[orderedPurls[i]] = i;
}
return items;
var aggregates = new Dictionary<(string Purl, string AdvisoryId, string Source), OverlayAggregate>(new OverlayKeyComparer());
foreach (var observation in observations.OrderByDescending(o => o.CreatedAt).ThenBy(o => o.ObservationId, StringComparer.Ordinal))
{
var observationRef = new GraphOverlayObservation(
observation.ObservationId,
observation.Upstream.ContentHash,
observation.Upstream.FetchedAt);
foreach (var statement in observation.Statements)
{
var targetPurls = ResolvePurls(statement, observation.Linkset.Purls);
foreach (var purl in targetPurls)
{
if (!purlOrder.ContainsKey(purl))
{
continue;
}
var key = (purl, statement.VulnerabilityId, observation.ProviderId);
if (!aggregates.TryGetValue(key, out var aggregate))
{
aggregate = new OverlayAggregate(purl, statement.VulnerabilityId, observation.ProviderId);
aggregates[key] = aggregate;
}
aggregate.UpdateStatus(statement.Status, observation.CreatedAt);
if (includeJustifications && statement.Justification is not null)
{
aggregate.AddJustification(statement.Justification.Value, observation.ObservationId);
}
aggregate.AddObservation(observationRef);
aggregate.AddConflicts(observation.Linkset.Disagreements);
aggregate.SetProvenance(
observation.StreamId ?? observation.ObservationId,
observation.Upstream.ContentHash,
observation.Upstream.ContentHash);
}
}
}
var overlays = aggregates.Values
.OrderBy(a => purlOrder[a.Purl])
.ThenBy(a => a.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.ThenBy(a => a.Source, StringComparer.OrdinalIgnoreCase)
.Select(a => a.ToOverlayItem(tenant, generatedAt, includeJustifications))
.ToList();
return overlays;
}
private static bool PurlMatches(VexObservationStatement stmt, string inputPurl, ImmutableArray<string> linksetPurls)
private static IReadOnlyList<string> ResolvePurls(VexObservationStatement stmt, ImmutableArray<string> linksetPurls)
{
if (!string.IsNullOrWhiteSpace(stmt.Purl) && stmt.Purl.Equals(inputPurl, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(stmt.Purl))
{
return true;
return new[] { stmt.Purl };
}
if (linksetPurls.IsDefaultOrEmpty)
{
return false;
return Array.Empty<string>();
}
return linksetPurls.Any(p => p.Equals(inputPurl, StringComparison.OrdinalIgnoreCase));
return linksetPurls.Where(p => !string.IsNullOrWhiteSpace(p)).ToArray();
}
private static string MapStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.UnderInvestigation => "under_investigation",
VexClaimStatus.Fixed => "fixed",
_ => "affected"
};
private sealed class OverlayAggregate
{
private readonly SortedSet<string> _observationHashes = new(StringComparer.Ordinal);
private readonly SortedSet<string> _observationIds = new(StringComparer.Ordinal);
private readonly List<GraphOverlayObservation> _observations = new();
private readonly List<GraphOverlayConflict> _conflicts = new();
private readonly List<GraphOverlayJustification> _justifications = new();
private DateTimeOffset? _latestCreatedAt;
private string? _status;
private string? _linksetId;
private string? _linksetHash;
private string? _policyHash;
private string? _sbomContextHash;
public OverlayAggregate(string purl, string advisoryId, string source)
{
Purl = purl;
AdvisoryId = advisoryId;
Source = source;
}
public string Purl { get; }
public string AdvisoryId { get; }
public string Source { get; }
public void UpdateStatus(VexClaimStatus status, DateTimeOffset createdAt)
{
if (_latestCreatedAt is null || createdAt > _latestCreatedAt.Value)
{
_latestCreatedAt = createdAt;
_status = MapStatus(status);
}
}
public void AddJustification(VexJustification justification, string observationId)
{
var kind = justification.ToString();
if (string.IsNullOrWhiteSpace(kind))
{
return;
}
_justifications.Add(new GraphOverlayJustification(
kind,
kind,
new[] { observationId },
null));
}
public void AddObservation(GraphOverlayObservation observation)
{
if (_observationIds.Add(observation.Id))
{
_observations.Add(observation);
}
_observationHashes.Add(observation.ContentHash);
}
public void AddConflicts(ImmutableArray<VexObservationDisagreement> disagreements)
{
if (disagreements.IsDefaultOrEmpty)
{
return;
}
foreach (var disagreement in disagreements)
{
_conflicts.Add(new GraphOverlayConflict(
"status",
disagreement.Justification ?? disagreement.Status,
new[] { disagreement.Status },
new[] { disagreement.ProviderId }));
}
}
public void SetProvenance(string linksetId, string linksetHash, string observationHash)
{
_linksetId ??= linksetId;
_linksetHash ??= linksetHash;
_policyHash ??= null;
_sbomContextHash ??= null;
_observationHashes.Add(observationHash);
}
public GraphOverlayItem ToOverlayItem(string tenant, DateTimeOffset generatedAt, bool includeJustifications)
{
return new GraphOverlayItem(
SchemaVersion: "1.0.0",
GeneratedAt: generatedAt,
Tenant: tenant,
Purl: Purl,
AdvisoryId: AdvisoryId,
Source: Source,
Status: _status ?? "unknown",
Justifications: includeJustifications ? _justifications : Array.Empty<GraphOverlayJustification>(),
Conflicts: _conflicts,
Observations: _observations,
Provenance: new GraphOverlayProvenance(
LinksetId: _linksetId ?? string.Empty,
LinksetHash: _linksetHash ?? string.Empty,
ObservationHashes: _observationHashes.ToArray(),
PolicyHash: _policyHash,
SbomContextHash: _sbomContextHash,
PlanCacheKey: null),
Cache: null);
}
}
private sealed class OverlayKeyComparer : IEqualityComparer<(string Purl, string AdvisoryId, string Source)>
{
public bool Equals((string Purl, string AdvisoryId, string Source) x, (string Purl, string AdvisoryId, string Source) y)
{
return string.Equals(x.Purl, y.Purl, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.AdvisoryId, y.AdvisoryId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Source, y.Source, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode((string Purl, string AdvisoryId, string Source) obj)
{
var hash = new HashCode();
hash.Add(obj.Purl, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.AdvisoryId, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.Source, StringComparer.OrdinalIgnoreCase);
return hash.ToHashCode();
}
}
}

View File

@@ -9,9 +9,16 @@ namespace StellaOps.Excititor.WebService.Graph;
internal static class GraphStatusFactory
{
public static IReadOnlyList<GraphStatusItem> Build(
string tenant,
DateTimeOffset generatedAt,
IReadOnlyList<string> orderedPurls,
IReadOnlyList<VexObservation> observations)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("tenant is required", nameof(tenant));
}
if (orderedPurls is null)
{
throw new ArgumentNullException(nameof(orderedPurls));
@@ -22,15 +29,74 @@ internal static class GraphStatusFactory
throw new ArgumentNullException(nameof(observations));
}
var overlays = GraphOverlayFactory.Build(orderedPurls, observations, includeJustifications: false);
var overlays = GraphOverlayFactory.Build(tenant, generatedAt, orderedPurls, observations, includeJustifications: false);
return overlays
.Select(overlay => new GraphStatusItem(
overlay.Purl,
overlay.Summary,
overlay.LatestModifiedAt,
overlay.Provenance.Sources,
overlay.Provenance.LastEvidenceHash))
.ToList();
var items = new List<GraphStatusItem>(orderedPurls.Count);
foreach (var purl in orderedPurls)
{
var overlaysForPurl = overlays
.Where(o => o.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase))
.ToList();
if (overlaysForPurl.Count == 0)
{
items.Add(new GraphStatusItem(
purl,
new GraphOverlaySummary(0, 0, 0, 1),
null,
Array.Empty<string>(),
null));
continue;
}
var open = 0;
var notAffected = 0;
var underInvestigation = 0;
var noStatement = 0;
var sources = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var observationRefs = new List<GraphOverlayObservation>();
foreach (var overlay in overlaysForPurl)
{
sources.Add(overlay.Source);
observationRefs.AddRange(overlay.Observations);
switch (overlay.Status)
{
case "not_affected":
notAffected++;
break;
case "under_investigation":
underInvestigation++;
break;
case "fixed":
case "affected":
open++;
break;
default:
noStatement++;
break;
}
}
var latest = observationRefs.Count == 0
? (DateTimeOffset?)null
: observationRefs.Max(o => o.FetchedAt);
var lastHash = observationRefs
.OrderBy(o => o.FetchedAt)
.ThenBy(o => o.Id, StringComparer.Ordinal)
.LastOrDefault()
?.ContentHash;
items.Add(new GraphStatusItem(
purl,
new GraphOverlaySummary(open, notAffected, underInvestigation, noStatement),
latest,
sources.ToArray(),
lastHash));
}
return items;
}
}

View File

@@ -8,6 +8,7 @@ public sealed class GraphOptions
public int MaxPurls { get; set; } = 500;
public int MaxAdvisoriesPerPurl { get; set; } = 200;
public int OverlayTtlSeconds { get; set; } = 300;
public bool UsePostgresOverlayStore { get; set; } = true;
public int MaxTooltipItemsPerPurl { get; set; } = 50;
public int MaxTooltipTotal { get; set; } = 1000;
}

View File

@@ -15,7 +15,7 @@ public partial class Program
{
private const string TenantHeaderName = "X-Stella-Tenant";
private static bool TryResolveTenant(HttpContext context, VexStorageOptions options, bool requireHeader, out string tenant, out IResult? problem)
internal static bool TryResolveTenant(HttpContext context, VexStorageOptions options, bool requireHeader, out string tenant, out IResult? problem)
{
tenant = options.DefaultTenant;
problem = null;
@@ -149,7 +149,7 @@ public partial class Program
return builder.ToImmutable();
}
private static DateTimeOffset? ParseSinceTimestamp(StringValues values)
internal static DateTimeOffset? ParseSinceTimestamp(StringValues values)
{
if (values.Count == 0)
{
@@ -244,7 +244,8 @@ public partial class Program
IReadOnlyList<GraphStatusItem> Items,
DateTimeOffset CachedAt);
private sealed record CachedGraphOverlay(
IReadOnlyList<GraphOverlayItem> Items,
DateTimeOffset CachedAt);
internal static string[] NormalizeValues(StringValues values) =>
values.Where(static v => !string.IsNullOrWhiteSpace(v))
.Select(static v => v!.Trim())
.ToArray();
}

View File

@@ -21,6 +21,7 @@ using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
@@ -28,6 +29,7 @@ using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Excititor.WebService.Endpoints;
using StellaOps.Excititor.WebService.Extensions;
using StellaOps.Excititor.WebService.Options;
@@ -46,10 +48,12 @@ var services = builder.Services;
services.AddOptions<VexStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage"))
.ValidateOnStart();
services.AddOptions<GraphOptions>()
.Bind(configuration.GetSection("Excititor:Graph"));
services.AddExcititorPostgresStorage(configuration);
services.TryAddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.TryAddSingleton<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.TryAddScoped<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.TryAddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
@@ -62,7 +66,24 @@ services.AddSingleton<AirgapSignerTrustService>();
services.AddSingleton<AirgapModeEnforcer>();
services.AddSingleton<ConsoleTelemetry>();
services.AddMemoryCache();
services.AddSingleton<IGraphOverlayCache, GraphOverlayCacheStore>();
services.AddSingleton<IGraphOverlayStore>(sp =>
{
var graphOptions = sp.GetRequiredService<IOptions<GraphOptions>>().Value;
var pgOptions = sp.GetRequiredService<IOptions<PostgresOptions>>().Value;
if (graphOptions.UsePostgresOverlayStore && !string.IsNullOrWhiteSpace(pgOptions.ConnectionString))
{
return new PostgresGraphOverlayStore(
sp.GetRequiredService<ExcititorDataSource>(),
sp.GetRequiredService<ILogger<PostgresGraphOverlayStore>>());
}
return new InMemoryGraphOverlayStore();
});
services.AddSingleton<IVexEvidenceLockerService, VexEvidenceLockerService>();
services.AddSingleton<IVexEvidenceAttestor, StellaOps.Excititor.Attestation.Evidence.VexEvidenceAttestor>();
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
services.AddSingleton<VexStatementBackfillService>();
services.AddOptions<ExcititorObservabilityOptions>()
.Bind(configuration.GetSection("Excititor:Observability"));
services.AddScoped<ExcititorHealthService>();
@@ -93,7 +114,7 @@ services.AddSingleton<IVexObservationProjectionService, VexObservationProjection
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
// EXCITITOR-RISK-66-001: Risk feed service for Risk Engine integration
services.AddScoped<StellaOps.Excititor.Core.RiskFeed.IRiskFeedService, StellaOps.Excititor.Core.RiskFeed.RiskFeedService>();
services.AddScoped<StellaOps.Excititor.Core.RiskFeed.IRiskFeedService, OverlayRiskFeedService>();
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
@@ -1505,7 +1526,7 @@ app.MapGet("/v1/graph/status", async (
return Results.BadRequest(ex.Message);
}
var items = GraphStatusFactory.Build(orderedPurls, result.Observations);
var items = GraphStatusFactory.Build(tenant!, timeProvider.GetUtcNow(), orderedPurls, result.Observations);
var response = new GraphStatusResponse(items, false, null);
cache.Set(cacheKey, new CachedGraphStatus(items, now), TimeSpan.FromSeconds(graphOptions.Value.OverlayTtlSeconds));
@@ -1521,7 +1542,8 @@ app.MapGet("/v1/graph/overlays", async (
IOptions<VexStorageOptions> storageOptions,
IOptions<GraphOptions> graphOptions,
IVexObservationQueryService queryService,
IMemoryCache cache,
IGraphOverlayCache overlayCache,
IGraphOverlayStore overlayStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
@@ -1541,13 +1563,12 @@ app.MapGet("/v1/graph/overlays", async (
return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})");
}
var cacheKey = $"graph-overlays:{tenant}:{includeJustifications}:{string.Join('|', orderedPurls)}";
var now = timeProvider.GetUtcNow();
if (cache.TryGetValue<CachedGraphOverlay>(cacheKey, out var cached) && cached is not null)
var cached = await overlayCache.TryGetAsync(tenant!, includeJustifications, orderedPurls, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
var ageMs = (long)Math.Max(0, (now - cached.CachedAt).TotalMilliseconds);
return Results.Ok(new GraphOverlaysResponse(cached.Items, true, ageMs));
return Results.Ok(new GraphOverlaysResponse(cached.Items, true, cached.AgeMilliseconds));
}
var options = new VexObservationQueryOptions(
@@ -1565,10 +1586,11 @@ app.MapGet("/v1/graph/overlays", async (
return Results.BadRequest(ex.Message);
}
var overlays = GraphOverlayFactory.Build(orderedPurls, result.Observations, includeJustifications);
var overlays = GraphOverlayFactory.Build(tenant!, now, orderedPurls, result.Observations, includeJustifications);
await overlayStore.SaveAsync(tenant!, overlays, cancellationToken).ConfigureAwait(false);
var response = new GraphOverlaysResponse(overlays, false, null);
cache.Set(cacheKey, new CachedGraphOverlay(overlays, now), TimeSpan.FromSeconds(graphOptions.Value.OverlayTtlSeconds));
await overlayCache.SaveAsync(tenant!, includeJustifications, orderedPurls, overlays, now, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}).WithName("GetGraphOverlays");
@@ -1712,8 +1734,9 @@ app.MapGet("/vex/raw", async (
var formatFilter = query.TryGetValue("format", out var formats)
? formats
.Where(static f => !string.IsNullOrWhiteSpace(f))
.Select(static f => Enum.TryParse<VexDocumentFormat>(f, true, out var parsed) ? parsed : VexDocumentFormat.Unknown)
.Where(static f => f != VexDocumentFormat.Unknown)
.Select(static f => Enum.TryParse<VexDocumentFormat>(f, true, out var parsed) ? parsed : (VexDocumentFormat?)null)
.Where(static f => f is not null)
.Select(static f => f!.Value)
.ToArray()
: Array.Empty<VexDocumentFormat>();
@@ -1910,112 +1933,6 @@ app.MapGet("/v1/vex/observations/{vulnerabilityId}/{productKey}", async (
return Results.Json(response);
});
app.MapGet("/v1/vex/evidence/chunks", async (
HttpContext context,
[FromServices] IVexEvidenceChunkService chunkService,
[FromServices] IOptions<VexStorageOptions> storageOptions,
[FromServices] ChunkTelemetry chunkTelemetry,
[FromServices] ILogger<VexEvidenceChunkRequest> logger,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var start = Stopwatch.GetTimestamp();
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
chunkTelemetry.RecordIngested(null, null, "unauthorized", "missing-scope", 0, 0, 0);
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
{
chunkTelemetry.RecordIngested(tenant, null, "rejected", "tenant-invalid", 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return tenantError;
}
var vulnerabilityId = context.Request.Query["vulnerabilityId"].FirstOrDefault();
var productKey = context.Request.Query["productKey"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return ValidationProblem("vulnerabilityId and productKey are required.");
}
var providerFilter = BuildStringFilterSet(context.Request.Query["providerId"]);
var statusFilter = BuildStatusFilter(context.Request.Query["status"]);
var since = ParseSinceTimestamp(context.Request.Query["since"]);
var limit = ResolveLimit(context.Request.Query["limit"], defaultValue: 200, min: 1, max: 500);
var request = new VexEvidenceChunkRequest(
tenant,
vulnerabilityId.Trim(),
productKey.Trim(),
providerFilter,
statusFilter,
since,
limit);
VexEvidenceChunkResult result;
try
{
result = await chunkService.QueryAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "cancelled");
chunkTelemetry.RecordIngested(tenant, providerFilter.Count > 0 ? string.Join(',', providerFilter) : null, "cancelled", null, 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
catch
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "error");
chunkTelemetry.RecordIngested(tenant, providerFilter.Count > 0 ? string.Join(',', providerFilter) : null, "error", null, 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
throw;
}
EvidenceTelemetry.RecordChunkOutcome(tenant, "success", result.Chunks.Count, result.Truncated);
EvidenceTelemetry.RecordChunkSignatureStatus(tenant, result.Chunks);
logger.LogInformation(
"vex_evidence_chunks_success tenant={Tenant} vulnerabilityId={Vuln} productKey={ProductKey} providers={Providers} statuses={Statuses} limit={Limit} total={Total} truncated={Truncated} returned={Returned}",
tenant ?? "(default)",
request.VulnerabilityId,
request.ProductKey,
providerFilter.Count,
statusFilter.Count,
request.Limit,
result.TotalCount,
result.Truncated,
result.Chunks.Count);
// Align headers with published contract.
context.Response.Headers["Excititor-Results-Total"] = result.TotalCount.ToString(CultureInfo.InvariantCulture);
context.Response.Headers["Excititor-Results-Truncated"] = result.Truncated ? "true" : "false";
context.Response.ContentType = "application/x-ndjson";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
long payloadBytes = 0;
foreach (var chunk in result.Chunks)
{
var line = JsonSerializer.Serialize(chunk, options);
payloadBytes += Encoding.UTF8.GetByteCount(line) + 1;
await context.Response.WriteAsync(line, cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync("\n", cancellationToken).ConfigureAwait(false);
}
var elapsedMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds;
chunkTelemetry.RecordIngested(
tenant,
providerFilter.Count > 0 ? string.Join(',', providerFilter) : null,
"success",
null,
result.TotalCount,
payloadBytes,
elapsedMs);
return Results.Empty;
});
app.MapPost("/aoc/verify", async (
HttpContext context,
VexAocVerifyRequest? request,
@@ -2060,10 +1977,10 @@ app.MapPost("/aoc/verify", async (
sources ?? Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<VexDocumentFormat>(),
since: new DateTimeOffset(since, TimeSpan.Zero),
until: new DateTimeOffset(until, TimeSpan.Zero),
cursor: null,
limit),
Since: new DateTimeOffset(since, TimeSpan.Zero),
Until: new DateTimeOffset(until, TimeSpan.Zero),
Cursor: null,
Limit: limit),
cancellationToken).ConfigureAwait(false);
var checkedCount = 0;

View File

@@ -279,7 +279,7 @@ internal sealed class ExcititorHealthService
Array.Empty<string>(),
Array.Empty<VexDocumentFormat>(),
windowStart,
until: null,
Until: null,
Cursor: null,
Limit: 500),
cancellationToken).ConfigureAwait(false);
@@ -360,13 +360,13 @@ internal sealed class ExcititorHealthService
foreach (var linkset in linksets)
{
if (linkset.Disagreements.Count == 0)
if (linkset.Disagreements.Length == 0)
{
continue;
}
docsWithConflicts++;
totalConflicts += linkset.Disagreements.Count;
totalConflicts += linkset.Disagreements.Length;
foreach (var disagreement in linkset.Disagreements)
{
@@ -381,8 +381,8 @@ internal sealed class ExcititorHealthService
var alignedTicks = AlignTicks(linkset.UpdatedAt.UtcDateTime, bucketTicks);
timeline[alignedTicks] = timeline.TryGetValue(alignedTicks, out var currentCount)
? currentCount + linkset.Disagreements.Count
: linkset.Disagreements.Count;
? currentCount + linkset.Disagreements.Length
: linkset.Disagreements.Length;
}
var trend = timeline

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Options;
namespace StellaOps.Excititor.WebService.Services;
public interface IGraphOverlayCache
{
ValueTask<GraphOverlayCacheHit?> TryGetAsync(string tenant, bool includeJustifications, IReadOnlyList<string> orderedPurls, CancellationToken cancellationToken);
ValueTask SaveAsync(string tenant, bool includeJustifications, IReadOnlyList<string> orderedPurls, IReadOnlyList<GraphOverlayItem> items, DateTimeOffset cachedAt, CancellationToken cancellationToken);
}
public sealed record GraphOverlayCacheHit(IReadOnlyList<GraphOverlayItem> Items, long AgeMilliseconds);
internal sealed class GraphOverlayCacheStore : IGraphOverlayCache
{
private readonly IMemoryCache _memoryCache;
private readonly IOptions<GraphOptions> _options;
private readonly TimeProvider _timeProvider;
public GraphOverlayCacheStore(IMemoryCache memoryCache, IOptions<GraphOptions> options, TimeProvider timeProvider)
{
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public ValueTask<GraphOverlayCacheHit?> TryGetAsync(string tenant, bool includeJustifications, IReadOnlyList<string> orderedPurls, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var key = BuildKey(tenant, includeJustifications, orderedPurls);
if (_memoryCache.TryGetValue<CachedOverlay>(key, out var cached) && cached is not null)
{
var ageMs = (long)Math.Max(0, (_timeProvider.GetUtcNow() - cached.CachedAt).TotalMilliseconds);
return ValueTask.FromResult<GraphOverlayCacheHit?>(new GraphOverlayCacheHit(cached.Items, ageMs));
}
return ValueTask.FromResult<GraphOverlayCacheHit?>(null);
}
public ValueTask SaveAsync(string tenant, bool includeJustifications, IReadOnlyList<string> orderedPurls, IReadOnlyList<GraphOverlayItem> items, DateTimeOffset cachedAt, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var key = BuildKey(tenant, includeJustifications, orderedPurls);
var ttl = TimeSpan.FromSeconds(Math.Max(1, _options.Value.OverlayTtlSeconds));
_memoryCache.Set(key, new CachedOverlay(items, cachedAt), ttl);
return ValueTask.CompletedTask;
}
private static string BuildKey(string tenant, bool includeJustifications, IReadOnlyList<string> orderedPurls)
=> $"graph-overlays:{tenant}:{includeJustifications}:{string.Join('|', orderedPurls)}";
private sealed record CachedOverlay(IReadOnlyList<GraphOverlayItem> Items, DateTimeOffset CachedAt);
}

View File

@@ -0,0 +1,154 @@
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Services;
public interface IGraphOverlayStore
{
ValueTask SaveAsync(string tenant, IReadOnlyList<GraphOverlayItem> overlays, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<GraphOverlayItem>> FindByPurlsAsync(string tenant, IReadOnlyCollection<string> purls, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<GraphOverlayItem>> FindByAdvisoriesAsync(string tenant, IReadOnlyCollection<string> advisories, int limit, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<GraphOverlayItem>> FindWithConflictsAsync(string tenant, int limit, CancellationToken cancellationToken);
}
/// <summary>
/// In-memory overlay store placeholder until Postgres materialization is added.
/// </summary>
public sealed class InMemoryGraphOverlayStore : IGraphOverlayStore
{
private readonly Dictionary<string, Dictionary<string, List<GraphOverlayItem>>> _store = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
public ValueTask SaveAsync(string tenant, IReadOnlyList<GraphOverlayItem> overlays, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_lock)
{
if (!_store.TryGetValue(tenant, out var byPurl))
{
byPurl = new Dictionary<string, List<GraphOverlayItem>>(StringComparer.OrdinalIgnoreCase);
_store[tenant] = byPurl;
}
foreach (var overlay in overlays)
{
if (!byPurl.TryGetValue(overlay.Purl, out var list))
{
list = new List<GraphOverlayItem>();
byPurl[overlay.Purl] = list;
}
// replace existing advisory/source entry for deterministic latest overlay
var existingIndex = list.FindIndex(o =>
string.Equals(o.AdvisoryId, overlay.AdvisoryId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(o.Source, overlay.Source, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
list[existingIndex] = overlay;
}
else
{
list.Add(overlay);
}
}
}
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<GraphOverlayItem>> FindByPurlsAsync(string tenant, IReadOnlyCollection<string> purls, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (purls.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(Array.Empty<GraphOverlayItem>());
}
lock (_lock)
{
if (!_store.TryGetValue(tenant, out var byPurl))
{
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(Array.Empty<GraphOverlayItem>());
}
var ordered = new List<GraphOverlayItem>();
foreach (var purl in purls)
{
if (byPurl.TryGetValue(purl, out var list))
{
// Order overlays deterministically by advisory + source for stable outputs
ordered.AddRange(list
.OrderBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Source, StringComparer.OrdinalIgnoreCase));
}
}
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(ordered);
}
}
public ValueTask<IReadOnlyList<GraphOverlayItem>> FindByAdvisoriesAsync(string tenant, IReadOnlyCollection<string> advisories, int limit, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (advisories.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(Array.Empty<GraphOverlayItem>());
}
lock (_lock)
{
if (!_store.TryGetValue(tenant, out var byPurl))
{
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(Array.Empty<GraphOverlayItem>());
}
var results = new List<GraphOverlayItem>();
foreach (var kvp in byPurl)
{
foreach (var overlay in kvp.Value)
{
if (advisories.Contains(overlay.AdvisoryId, StringComparer.OrdinalIgnoreCase))
{
results.Add(overlay);
if (results.Count >= limit)
{
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(results);
}
}
}
}
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(results
.OrderBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Purl, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Source, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToList());
}
}
public ValueTask<IReadOnlyList<GraphOverlayItem>> FindWithConflictsAsync(string tenant, int limit, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_lock)
{
if (!_store.TryGetValue(tenant, out var byPurl))
{
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(Array.Empty<GraphOverlayItem>());
}
var results = byPurl.Values
.SelectMany(list => list)
.Where(o => o.Conflicts.Count > 0)
.OrderBy(o => o.Purl, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.ThenBy(o => o.Source, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<GraphOverlayItem>>(results);
}
}
}

View File

@@ -0,0 +1,170 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.RiskFeed;
using StellaOps.Excititor.Core.Observations;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Services;
/// <summary>
/// Risk feed service backed by graph overlays (EXCITITOR-RISK-66-001).
/// </summary>
public sealed class OverlayRiskFeedService : IRiskFeedService
{
private readonly IGraphOverlayStore _overlayStore;
private readonly TimeProvider _timeProvider;
public OverlayRiskFeedService(IGraphOverlayStore overlayStore, TimeProvider timeProvider)
{
_overlayStore = overlayStore ?? throw new ArgumentNullException(nameof(overlayStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<RiskFeedResponse> GenerateFeedAsync(RiskFeedRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var overlays = await ResolveOverlaysAsync(request, cancellationToken).ConfigureAwait(false);
var filtered = ApplySinceFilter(overlays, request.Since);
var items = filtered
.Select(MapToRiskFeedItem)
.Where(item => item is not null)
.Cast<RiskFeedItem>()
.OrderBy(item => item.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.Artifact, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.Provenance.TenantId, StringComparer.OrdinalIgnoreCase)
.Take(request.Limit)
.ToImmutableArray();
return new RiskFeedResponse(items, _timeProvider.GetUtcNow());
}
public async Task<RiskFeedItem?> GetItemAsync(string tenantId, string advisoryKey, string artifact, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
ArgumentException.ThrowIfNullOrWhiteSpace(artifact);
var overlays = await _overlayStore
.FindByPurlsAsync(tenantId, new[] { artifact }, cancellationToken)
.ConfigureAwait(false);
var match = overlays
.Where(o => string.Equals(o.AdvisoryId, advisoryKey, StringComparison.OrdinalIgnoreCase))
.OrderBy(o => o.Source, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
return match is null ? null : MapToRiskFeedItem(match);
}
private async Task<IReadOnlyList<GraphOverlayItem>> ResolveOverlaysAsync(RiskFeedRequest request, CancellationToken cancellationToken)
{
if (!request.AdvisoryKeys.IsDefaultOrEmpty)
{
return await _overlayStore
.FindByAdvisoriesAsync(request.TenantId, request.AdvisoryKeys, request.Limit, cancellationToken)
.ConfigureAwait(false);
}
if (!request.Artifacts.IsDefaultOrEmpty)
{
return await _overlayStore
.FindByPurlsAsync(request.TenantId, request.Artifacts, cancellationToken)
.ConfigureAwait(false);
}
return await _overlayStore
.FindWithConflictsAsync(request.TenantId, request.Limit, cancellationToken)
.ConfigureAwait(false);
}
private static IEnumerable<GraphOverlayItem> ApplySinceFilter(IEnumerable<GraphOverlayItem> overlays, DateTimeOffset? since)
{
if (since is null)
{
return overlays;
}
var threshold = since.Value;
return overlays.Where(o => o.GeneratedAt >= threshold);
}
private static RiskFeedItem? MapToRiskFeedItem(GraphOverlayItem overlay)
{
if (!TryParseStatus(overlay.Status, out var status))
{
return null;
}
var justification = ParseJustification(overlay.Justifications.FirstOrDefault()?.Kind);
var confidence = DeriveConfidence(overlay);
var provenance = new RiskFeedProvenance(
overlay.Tenant,
overlay.Provenance.LinksetId,
overlay.Provenance.LinksetHash,
confidence,
overlay.Conflicts.Count > 0,
overlay.GeneratedAt);
var observedAt = overlay.Observations.Count == 0
? overlay.GeneratedAt
: overlay.Observations.Max(o => o.FetchedAt);
var sources = overlay.Observations
.OrderBy(o => o.FetchedAt)
.Select(o => new RiskFeedObservationSource(
o.Id,
overlay.Source,
overlay.Status,
overlay.Justifications.FirstOrDefault()?.Kind,
null))
.ToImmutableArray();
return new RiskFeedItem(
overlay.AdvisoryId,
overlay.Purl,
status,
justification,
provenance,
observedAt,
sources);
}
private static bool TryParseStatus(string status, out VexClaimStatus parsed)
{
parsed = status.ToLowerInvariant() switch
{
"not_affected" => VexClaimStatus.NotAffected,
"under_investigation" => VexClaimStatus.UnderInvestigation,
"fixed" => VexClaimStatus.Fixed,
"affected" => VexClaimStatus.Affected,
_ => VexClaimStatus.UnderInvestigation
};
return true;
}
private static VexJustification? ParseJustification(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return Enum.TryParse<VexJustification>(value, true, out var justification) ? justification : null;
}
private static VexLinksetConfidence DeriveConfidence(GraphOverlayItem overlay)
{
if (overlay.Conflicts.Count > 0)
{
return VexLinksetConfidence.Low;
}
return overlay.Observations.Count > 1
? VexLinksetConfidence.High
: VexLinksetConfidence.Medium;
}
}

View File

@@ -0,0 +1,244 @@
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Excititor.Storage.Postgres;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Services;
/// <summary>
/// Postgres-backed overlay materialization store. Persists overlays per tenant/purl/advisory/source.
/// </summary>
public sealed class PostgresGraphOverlayStore : IGraphOverlayStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly ExcititorDataSource _dataSource;
private readonly ILogger<PostgresGraphOverlayStore> _logger;
private volatile bool _initialized;
private readonly SemaphoreSlim _initLock = new(1, 1);
public PostgresGraphOverlayStore(ExcititorDataSource dataSource, ILogger<PostgresGraphOverlayStore> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask SaveAsync(string tenant, IReadOnlyList<GraphOverlayItem> overlays, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(overlays);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO vex.graph_overlays (tenant, purl, advisory_id, source, generated_at, payload)
VALUES (@tenant, @purl, @advisory_id, @source, @generated_at, @payload)
ON CONFLICT (tenant, purl, advisory_id, source)
DO UPDATE SET generated_at = EXCLUDED.generated_at, payload = EXCLUDED.payload;
""";
foreach (var overlay in overlays)
{
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("tenant", tenant);
command.Parameters.AddWithValue("purl", overlay.Purl);
command.Parameters.AddWithValue("advisory_id", overlay.AdvisoryId);
command.Parameters.AddWithValue("source", overlay.Source);
command.Parameters.AddWithValue("generated_at", overlay.GeneratedAt.UtcDateTime);
command.Parameters.Add(new NpgsqlParameter("payload", NpgsqlDbType.Jsonb)
{
Value = JsonSerializer.Serialize(overlay, SerializerOptions)
});
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask<IReadOnlyList<GraphOverlayItem>> FindByPurlsAsync(string tenant, IReadOnlyCollection<string> purls, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(purls);
if (purls.Count == 0)
{
return Array.Empty<GraphOverlayItem>();
}
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT payload
FROM vex.graph_overlays
WHERE tenant = @tenant AND purl = ANY(@purls)
ORDER BY purl, advisory_id, source;
""";
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("tenant", tenant);
command.Parameters.Add(new NpgsqlParameter<string[]>("purls", NpgsqlDbType.Array | NpgsqlDbType.Text)
{
TypedValue = purls.ToArray()
});
var overlays = new List<GraphOverlayItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var payload = reader.GetString(0);
var overlay = JsonSerializer.Deserialize<GraphOverlayItem>(payload, SerializerOptions);
if (overlay is not null)
{
overlays.Add(overlay);
}
}
return overlays;
}
public async ValueTask<IReadOnlyList<GraphOverlayItem>> FindByAdvisoriesAsync(string tenant, IReadOnlyCollection<string> advisories, int limit, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(advisories);
if (advisories.Count == 0)
{
return Array.Empty<GraphOverlayItem>();
}
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT payload
FROM vex.graph_overlays
WHERE tenant = @tenant AND advisory_id = ANY(@advisories)
ORDER BY advisory_id, purl, source
LIMIT @limit;
""";
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("tenant", tenant);
command.Parameters.Add(new NpgsqlParameter<string[]>("advisories", NpgsqlDbType.Array | NpgsqlDbType.Text)
{
TypedValue = advisories.ToArray()
});
command.Parameters.AddWithValue("limit", limit);
var overlays = new List<GraphOverlayItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var payload = reader.GetString(0);
var overlay = JsonSerializer.Deserialize<GraphOverlayItem>(payload, SerializerOptions);
if (overlay is not null)
{
overlays.Add(overlay);
}
}
return overlays;
}
public async ValueTask<IReadOnlyList<GraphOverlayItem>> FindWithConflictsAsync(string tenant, int limit, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(tenant);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT payload
FROM vex.graph_overlays
WHERE tenant = @tenant
AND jsonb_array_length(payload -> 'conflicts') > 0
ORDER BY generated_at DESC, purl, advisory_id, source
LIMIT @limit;
""";
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("tenant", tenant);
command.Parameters.AddWithValue("limit", limit);
var overlays = new List<GraphOverlayItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var payload = reader.GetString(0);
var overlay = JsonSerializer.Deserialize<GraphOverlayItem>(payload, SerializerOptions);
if (overlay is not null)
{
overlays.Add(overlay);
}
}
return overlays;
}
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized)
{
return;
}
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
CREATE TABLE IF NOT EXISTS vex.graph_overlays (
tenant text NOT NULL,
purl text NOT NULL,
advisory_id text NOT NULL,
source text NOT NULL,
generated_at timestamptz NOT NULL,
payload jsonb NOT NULL,
CONSTRAINT pk_graph_overlays PRIMARY KEY (tenant, purl, advisory_id, source)
);
""";
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds
};
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogError(ex, "Failed to ensure graph_overlays table exists.");
throw;
}
finally
{
_initLock.Release();
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Excititor.WebService.Services;
public sealed record VexStatementBackfillRequest(int BatchSize = 500);
public sealed record VexStatementBackfillResult(
int DocumentsEvaluated,
int DocumentsBackfilled,
int ClaimsWritten,
int SkippedExisting,
int NormalizationFailures);
/// <summary>
/// Placeholder backfill service while legacy statement storage is removed.
/// </summary>
public sealed class VexStatementBackfillService
{
private readonly ILogger<VexStatementBackfillService> _logger;
public VexStatementBackfillService(ILogger<VexStatementBackfillService> logger)
{
_logger = logger;
}
public ValueTask<VexStatementBackfillResult> RunAsync(VexStatementBackfillRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("Vex statement backfill is currently a no-op; batchSize={BatchSize}", request.BatchSize);
return ValueTask.FromResult(new VexStatementBackfillResult(0, 0, 0, 0, 0));
}
}

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -50,7 +51,7 @@ services.AddOptions<VexStorageOptions>()
services.AddExcititorPostgresStorage(configuration);
services.AddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.AddSingleton<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.TryAddScoped<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.AddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();

View File

@@ -0,0 +1,90 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core.Storage;
public sealed class DuplicateAirgapImportException : Exception
{
public DuplicateAirgapImportException(string message)
: base(message)
{
}
}
/// <summary>
/// Timeline entry for an imported airgap bundle.
/// </summary>
public sealed record AirgapTimelineEntry
{
public string EventType { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
public string TenantId { get; init; } = "default";
public string BundleId { get; init; } = string.Empty;
public string MirrorGeneration { get; init; } = string.Empty;
public int? StalenessSeconds { get; init; }
public string? ErrorCode { get; init; }
public string? Message { get; init; }
public string? Remediation { get; init; }
public string? Actor { get; init; }
public string? Scopes { get; init; }
}
/// <summary>
/// Persisted airgap import record describing a mirror bundle and associated metadata.
/// </summary>
public sealed record AirgapImportRecord
{
public string Id { get; init; } = string.Empty;
public string TenantId { get; init; } = "default";
public string BundleId { get; init; } = string.Empty;
public string MirrorGeneration { get; init; } = "0";
public string Publisher { get; init; } = string.Empty;
public DateTimeOffset SignedAt { get; init; }
public DateTimeOffset ImportedAt { get; init; }
public string PayloadHash { get; init; } = string.Empty;
public string? PayloadUrl { get; init; }
public string Signature { get; init; } = string.Empty;
public string? TransparencyLog { get; init; }
public string? PortableManifestPath { get; init; }
public string? PortableManifestHash { get; init; }
public string? EvidenceLockerPath { get; init; }
public IReadOnlyList<AirgapTimelineEntry> Timeline { get; init; } = Array.Empty<AirgapTimelineEntry>();
public string? ImportActor { get; init; }
public string? ImportScopes { get; init; }
}
public interface IAirgapImportStore
{
Task SaveAsync(AirgapImportRecord record, CancellationToken cancellationToken);
Task<AirgapImportRecord?> FindByBundleIdAsync(string tenantId, string bundleId, string? mirrorGeneration, CancellationToken cancellationToken);
Task<IReadOnlyList<AirgapImportRecord>> ListAsync(string tenantId, string? publisherFilter, DateTimeOffset? importedAfter, int limit, int offset, CancellationToken cancellationToken);
Task<int> CountAsync(string tenantId, string? publisherFilter, DateTimeOffset? importedAfter, CancellationToken cancellationToken);
}

View File

@@ -11,16 +11,24 @@ public sealed record VexConnectorState(
string ConnectorId,
DateTimeOffset? LastUpdated,
ImmutableArray<string> DocumentDigests,
ImmutableDictionary<string, string> ResumeTokens = default,
ImmutableDictionary<string, string>? ResumeTokens = null,
DateTimeOffset? LastSuccessAt = null,
int FailureCount = 0,
DateTimeOffset? NextEligibleRun = null,
string? LastFailureReason = null,
DateTimeOffset? LastCheckpoint = null)
DateTimeOffset? LastCheckpoint = null,
DateTimeOffset? LastHeartbeatAt = null,
string? LastHeartbeatStatus = null,
string? LastArtifactHash = null,
string? LastArtifactKind = null)
{
public ImmutableDictionary<string, string> ResumeTokens { get; init; } = ResumeTokens.IsDefault
? ImmutableDictionary<string, string>.Empty
: ResumeTokens;
public ImmutableArray<string> DocumentDigests { get; init; } =
DocumentDigests.IsDefault ? ImmutableArray<string>.Empty : DocumentDigests;
public ImmutableDictionary<string, string> ResumeTokens { get; init; } =
ResumeTokens is null || ResumeTokens.Count == 0
? ImmutableDictionary<string, string>.Empty
: ResumeTokens;
};
/// <summary>

View File

@@ -212,7 +212,7 @@ public sealed class InMemoryVexRawStore : IVexRawStore
private static byte[] CanonicalizeJson(ReadOnlyMemory<byte> content)
{
using var jsonDocument = JsonDocument.Parse(content);
using var buffer = new ArrayBufferWriter<byte>();
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false }))
{
WriteCanonical(writer, jsonDocument.RootElement);
@@ -396,7 +396,7 @@ public sealed class InMemoryAppendOnlyLinksetStore : IAppendOnlyLinksetStore, IV
tenant,
vulnerabilityId,
productKey,
new VexProductScope(productKey, null, null, productKey, null, Array.Empty<string>()),
new VexProductScope(productKey, "unknown", null, productKey, null, ImmutableArray<string>.Empty),
Enumerable.Empty<VexLinksetObservationRefModel>(),
Enumerable.Empty<VexObservationDisagreement>(),
DateTimeOffset.UtcNow,
@@ -554,7 +554,7 @@ public sealed class InMemoryAppendOnlyLinksetStore : IAppendOnlyLinksetStore, IV
return ValueTask.FromResult(existing);
}
var scope = new VexProductScope(productKey, null, null, productKey, null, Array.Empty<string>());
var scope = new VexProductScope(productKey, "unknown", null, productKey, null, ImmutableArray<string>.Empty);
var linkset = new VexLinkset(linksetId, tenant, vulnerabilityId, productKey, scope, Enumerable.Empty<VexLinksetObservationRefModel>());
_linksets[key] = linkset;
AddMutation(key, LinksetMutationEvent.MutationTypes.LinksetCreated, null, null, null, null);

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Excititor.Core.Storage;
/// <summary>
/// Persistence abstraction for resolved VEX consensus documents.
/// </summary>
public interface IVexConsensusStore
{
ValueTask SaveAsync(VexConsensus consensus, CancellationToken cancellationToken);
ValueTask<VexConsensus?> FindAsync(string vulnerabilityId, string productKey, CancellationToken cancellationToken);
IAsyncEnumerable<VexConsensus> FindCalculatedBeforeAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,35 @@
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
/// <summary>
/// Persisted manifest store for export runs keyed by query signature and format.
/// </summary>
public interface IVexExportStore
{
ValueTask<VexExportManifest?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken);
ValueTask SaveAsync(VexExportManifest manifest, CancellationToken cancellationToken);
}
/// <summary>
/// Cache index used to track export cache entries by signature and format.
/// </summary>
public interface IVexCacheIndex
{
ValueTask<VexCacheEntry?> FindAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken);
ValueTask SaveAsync(VexCacheEntry entry, CancellationToken cancellationToken);
ValueTask RemoveAsync(VexQuerySignature signature, VexExportFormat format, CancellationToken cancellationToken);
}
/// <summary>
/// Maintenance operations for keeping the export cache consistent.
/// </summary>
public interface IVexCacheMaintenance
{
ValueTask<int> RemoveExpiredAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
ValueTask<int> RemoveMissingManifestReferencesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Excititor.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed connector state repository for orchestrator checkpoints and heartbeats.
/// </summary>
public sealed class PostgresConnectorStateRepository : RepositoryBase<ExcititorDataSource>, IVexConnectorStateRepository
{
private volatile bool _initialized;
private readonly SemaphoreSlim _initLock = new(1, 1);
public PostgresConnectorStateRepository(ExcititorDataSource dataSource, ILogger<PostgresConnectorStateRepository> logger)
: base(dataSource, logger)
{
}
public async ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectorId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT connector_id, last_updated, document_digests, resume_tokens, last_success_at, failure_count,
next_eligible_run, last_failure_reason, last_checkpoint, last_heartbeat_at, last_heartbeat_status,
last_artifact_hash, last_artifact_kind
FROM vex.connector_states
WHERE connector_id = @connector_id;
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "connector_id", connectorId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return Map(reader);
}
public async ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var lastUpdated = state.LastUpdated ?? DateTimeOffset.UtcNow;
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO vex.connector_states (
connector_id, last_updated, document_digests, resume_tokens, last_success_at, failure_count,
next_eligible_run, last_failure_reason, last_checkpoint, last_heartbeat_at, last_heartbeat_status,
last_artifact_hash, last_artifact_kind)
VALUES (
@connector_id, @last_updated, @document_digests, @resume_tokens, @last_success_at, @failure_count,
@next_eligible_run, @last_failure_reason, @last_checkpoint, @last_heartbeat_at, @last_heartbeat_status,
@last_artifact_hash, @last_artifact_kind)
ON CONFLICT (connector_id) DO UPDATE SET
last_updated = EXCLUDED.last_updated,
document_digests = EXCLUDED.document_digests,
resume_tokens = EXCLUDED.resume_tokens,
last_success_at = EXCLUDED.last_success_at,
failure_count = EXCLUDED.failure_count,
next_eligible_run = EXCLUDED.next_eligible_run,
last_failure_reason = EXCLUDED.last_failure_reason,
last_checkpoint = EXCLUDED.last_checkpoint,
last_heartbeat_at = EXCLUDED.last_heartbeat_at,
last_heartbeat_status = EXCLUDED.last_heartbeat_status,
last_artifact_hash = EXCLUDED.last_artifact_hash,
last_artifact_kind = EXCLUDED.last_artifact_kind;
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "connector_id", state.ConnectorId);
AddParameter(command, "last_updated", lastUpdated.UtcDateTime);
AddParameter(command, "document_digests", state.DocumentDigests.IsDefault ? Array.Empty<string>() : state.DocumentDigests.ToArray());
AddJsonbParameter(command, "resume_tokens", JsonSerializer.Serialize(state.ResumeTokens));
AddParameter(command, "last_success_at", state.LastSuccessAt?.UtcDateTime);
AddParameter(command, "failure_count", state.FailureCount);
AddParameter(command, "next_eligible_run", state.NextEligibleRun?.UtcDateTime);
AddParameter(command, "last_failure_reason", state.LastFailureReason);
AddParameter(command, "last_checkpoint", state.LastCheckpoint?.UtcDateTime);
AddParameter(command, "last_heartbeat_at", state.LastHeartbeatAt?.UtcDateTime);
AddParameter(command, "last_heartbeat_status", state.LastHeartbeatStatus);
AddParameter(command, "last_artifact_hash", state.LastArtifactHash);
AddParameter(command, "last_artifact_kind", state.LastArtifactKind);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT connector_id, last_updated, document_digests, resume_tokens, last_success_at, failure_count,
next_eligible_run, last_failure_reason, last_checkpoint, last_heartbeat_at, last_heartbeat_status,
last_artifact_hash, last_artifact_kind
FROM vex.connector_states
ORDER BY connector_id;
""";
await using var command = CreateCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<VexConnectorState>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(Map(reader));
}
return results;
}
private VexConnectorState Map(NpgsqlDataReader reader)
{
var connectorId = reader.GetString(0);
var lastUpdated = reader.IsDBNull(1) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(1), TimeSpan.Zero);
var digests = reader.IsDBNull(2) ? ImmutableArray<string>.Empty : reader.GetFieldValue<string[]>(2).ToImmutableArray();
var resumeTokens = reader.IsDBNull(3)
? ImmutableDictionary<string, string>.Empty
: JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(reader.GetFieldValue<string>(3)) ?? ImmutableDictionary<string, string>.Empty;
var lastSuccess = reader.IsDBNull(4) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(4), TimeSpan.Zero);
var failureCount = reader.IsDBNull(5) ? 0 : reader.GetInt32(5);
var nextEligible = reader.IsDBNull(6) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(6), TimeSpan.Zero);
var lastFailureReason = reader.IsDBNull(7) ? null : reader.GetString(7);
var lastCheckpoint = reader.IsDBNull(8) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(8), TimeSpan.Zero);
var lastHeartbeatAt = reader.IsDBNull(9) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(9), TimeSpan.Zero);
var lastHeartbeatStatus = reader.IsDBNull(10) ? null : reader.GetString(10);
var lastArtifactHash = reader.IsDBNull(11) ? null : reader.GetString(11);
var lastArtifactKind = reader.IsDBNull(12) ? null : reader.GetString(12);
return new VexConnectorState(
connectorId,
lastUpdated,
digests,
resumeTokens,
lastSuccess,
failureCount,
nextEligible,
lastFailureReason,
lastCheckpoint,
lastHeartbeatAt,
lastHeartbeatStatus,
lastArtifactHash,
lastArtifactKind);
}
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized)
{
return;
}
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
CREATE TABLE IF NOT EXISTS vex.connector_states (
connector_id text PRIMARY KEY,
last_updated timestamptz NOT NULL,
document_digests text[] NOT NULL,
resume_tokens jsonb NOT NULL DEFAULT '{}'::jsonb,
last_success_at timestamptz NULL,
failure_count integer NOT NULL DEFAULT 0,
next_eligible_run timestamptz NULL,
last_failure_reason text NULL,
last_checkpoint timestamptz NULL,
last_heartbeat_at timestamptz NULL,
last_heartbeat_status text NULL,
last_artifact_hash text NULL,
last_artifact_kind text NULL
);
""";
await using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
finally
{
_initLock.Release();
}
}
}

View File

@@ -90,8 +90,9 @@ public sealed class PostgresVexRawStore : RepositoryBase<ExcititorDataSource>, I
ON CONFLICT (digest) DO NOTHING;
""";
await using (var command = CreateCommand(insertDocumentSql, connection, transaction))
await using (var command = CreateCommand(insertDocumentSql, connection))
{
command.Transaction = transaction;
AddParameter(command, "digest", digest);
AddParameter(command, "tenant", tenant);
AddParameter(command, "provider_id", providerId);
@@ -117,7 +118,8 @@ public sealed class PostgresVexRawStore : RepositoryBase<ExcititorDataSource>, I
ON CONFLICT (digest) DO NOTHING;
""";
await using var blobCommand = CreateCommand(insertBlobSql, connection, transaction);
await using var blobCommand = CreateCommand(insertBlobSql, connection);
blobCommand.Transaction = transaction;
AddParameter(blobCommand, "digest", digest);
blobCommand.Parameters.Add(new NpgsqlParameter("payload", NpgsqlDbType.Bytea)
{
@@ -320,9 +322,15 @@ public sealed class PostgresVexRawStore : RepositoryBase<ExcititorDataSource>, I
}
private static VexDocumentFormat ParseFormat(string value)
=> Enum.TryParse<VexDocumentFormat>(value, ignoreCase: true, out var parsed)
? parsed
: VexDocumentFormat.Unknown;
{
if (Enum.TryParse<VexDocumentFormat>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
// Default to OpenVEX for unknown/legacy values to preserve compatibility with legacy rows.
return VexDocumentFormat.OpenVex;
}
private static ImmutableDictionary<string, string> ParseMetadata(string json)
{

View File

@@ -34,6 +34,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAppendOnlyLinksetStore, PostgresAppendOnlyLinksetStore>();
services.AddScoped<IVexLinksetStore, PostgresAppendOnlyLinksetStore>();
services.AddScoped<IVexRawStore, PostgresVexRawStore>();
services.AddScoped<IVexConnectorStateRepository, PostgresConnectorStateRepository>();
return services;
}
@@ -56,6 +57,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAppendOnlyLinksetStore, PostgresAppendOnlyLinksetStore>();
services.AddScoped<IVexLinksetStore, PostgresAppendOnlyLinksetStore>();
services.AddScoped<IVexRawStore, PostgresVexRawStore>();
services.AddScoped<IVexConnectorStateRepository, PostgresConnectorStateRepository>();
return services;
}

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
using Xunit;

View File

@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Options;
using Xunit;

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class GraphOverlayCacheTests
{
[Fact]
public async Task SaveAndGet_RoundTripsOverlay()
{
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var options = Options.Create(new GraphOptions { OverlayTtlSeconds = 300 });
var cache = new GraphOverlayCacheStore(memoryCache, options, TimeProvider.System);
var overlays = new[]
{
new GraphOverlayItem(
SchemaVersion: "1.0.0",
GeneratedAt: DateTimeOffset.UtcNow,
Tenant: "tenant-a",
Purl: "pkg:npm/example@1.0.0",
AdvisoryId: "ADV-1",
Source: "provider",
Status: "not_affected",
Summary: new GraphOverlaySummary(0, 1, 0, 0),
Justifications: Array.Empty<GraphOverlayJustification>(),
Conflicts: Array.Empty<GraphOverlayConflict>(),
Observations: Array.Empty<GraphOverlayObservation>(),
Provenance: new GraphOverlayProvenance("tenant-a", new[] { "provider" }, new[] { "CVE-1" }, new[] { "pkg:npm/example@1.0.0" }, Array.Empty<string>(), Array.Empty<string>()),
Cache: null)
};
await cache.SaveAsync("tenant-a", includeJustifications: false, overlays.Select(o => o.Purl).ToArray(), overlays, DateTimeOffset.UtcNow, CancellationToken.None);
var hit = await cache.TryGetAsync("tenant-a", includeJustifications: false, overlays.Select(o => o.Purl).ToArray(), CancellationToken.None);
Assert.NotNull(hit);
Assert.Equal(overlays, hit!.Items);
Assert.True(hit.AgeMilliseconds >= 0);
}
}

View File

@@ -11,7 +11,7 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class GraphOverlayFactoryTests
{
[Fact]
public void Build_ComputesSummariesAndProvenancePerPurl()
public void Build_EmitsOverlayPerStatementWithProvenance()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
@@ -55,20 +55,27 @@ public sealed class GraphOverlayFactoryTests
};
var overlays = GraphOverlayFactory.Build(
tenant: "tenant-a",
generatedAt: now,
orderedPurls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
observations: observations,
includeJustifications: true);
var overlay = Assert.Single(overlays);
Assert.Equal("pkg:rpm/redhat/openssl@1.1.1", overlay.Purl);
Assert.Equal(0, overlay.Summary.Open);
Assert.Equal(1, overlay.Summary.NotAffected);
Assert.Equal(1, overlay.Summary.UnderInvestigation);
Assert.Equal(1, overlay.Summary.NoStatement);
Assert.Equal(now, overlay.LatestModifiedAt);
Assert.Equal(new[] { "ComponentNotPresent" }, overlay.Justifications);
Assert.Equal("hash-new", overlay.Provenance.LastEvidenceHash);
Assert.Equal(new[] { "oracle", "redhat", "ubuntu" }, overlay.Provenance.Sources);
Assert.Equal(2, overlays.Count);
var notAffected = Assert.Single(overlays.Where(o => o.Status == "not_affected"));
Assert.Equal("pkg:rpm/redhat/openssl@1.1.1", notAffected.Purl);
Assert.Equal("CVE-2025-1000", notAffected.AdvisoryId);
Assert.Equal("redhat", notAffected.Source);
Assert.Single(notAffected.Justifications);
Assert.Contains(notAffected.Observations, o => o.ContentHash == "hash-old");
Assert.Contains("hash-old", notAffected.Provenance.ObservationHashes);
var underInvestigation = Assert.Single(overlays.Where(o => o.Status == "under_investigation"));
Assert.Equal("CVE-2025-1001", underInvestigation.AdvisoryId);
Assert.Equal("ubuntu", underInvestigation.Source);
Assert.Empty(underInvestigation.Justifications);
Assert.Contains("hash-new", underInvestigation.Provenance.ObservationHashes);
}
private static VexObservation CreateObservation(

View File

@@ -0,0 +1,51 @@
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class GraphOverlayStoreTests
{
[Fact]
public async Task SaveAndFindByPurls_ReturnsLatestPerSourceAdvisory()
{
var store = new InMemoryGraphOverlayStore();
var overlays = new[]
{
new GraphOverlayItem(
SchemaVersion: "1.0.0",
GeneratedAt: DateTimeOffset.UtcNow.AddMinutes(-1),
Tenant: "tenant-a",
Purl: "pkg:npm/example@1.0.0",
AdvisoryId: "ADV-1",
Source: "provider-a",
Status: "not_affected",
Summary: new GraphOverlaySummary(0, 1, 0, 0),
Justifications: Array.Empty<GraphOverlayJustification>(),
Conflicts: Array.Empty<GraphOverlayConflict>(),
Observations: Array.Empty<GraphOverlayObservation>(),
Provenance: new GraphOverlayProvenance("tenant-a", new[] { "provider-a" }, new[] { "ADV-1" }, new[] { "pkg:npm/example@1.0.0" }, Array.Empty<string>(), Array.Empty<string>()),
Cache: null),
new GraphOverlayItem(
SchemaVersion: "1.0.0",
GeneratedAt: DateTimeOffset.UtcNow,
Tenant: "tenant-a",
Purl: "pkg:npm/example@1.0.0",
AdvisoryId: "ADV-1",
Source: "provider-a",
Status: "under_investigation",
Summary: new GraphOverlaySummary(0, 0, 1, 0),
Justifications: Array.Empty<GraphOverlayJustification>(),
Conflicts: Array.Empty<GraphOverlayConflict>(),
Observations: Array.Empty<GraphOverlayObservation>(),
Provenance: new GraphOverlayProvenance("tenant-a", new[] { "provider-a" }, new[] { "ADV-1" }, new[] { "pkg:npm/example@1.0.0" }, Array.Empty<string>(), Array.Empty<string>()),
Cache: null)
};
await store.SaveAsync("tenant-a", overlays, CancellationToken.None);
var results = await store.FindByPurlsAsync("tenant-a", new[] { "pkg:npm/example@1.0.0" }, CancellationToken.None);
var single = Assert.Single(results);
Assert.Equal("under_investigation", single.Status);
}
}

View File

@@ -10,7 +10,7 @@ namespace StellaOps.Excititor.WebService.Tests;
public sealed class GraphStatusFactoryTests
{
[Fact]
public void Build_ProjectsOverlaySummariesAndProvenance()
public void Build_ProjectsStatusCountsPerPurl()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
@@ -39,6 +39,8 @@ public sealed class GraphStatusFactoryTests
};
var items = GraphStatusFactory.Build(
tenant: "tenant-a",
generatedAt: now,
orderedPurls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
observations: observations);
@@ -47,10 +49,10 @@ public sealed class GraphStatusFactoryTests
Assert.Equal(0, item.Summary.Open);
Assert.Equal(1, item.Summary.NotAffected);
Assert.Equal(0, item.Summary.UnderInvestigation);
Assert.Equal(1, item.Summary.NoStatement);
Assert.Equal(0, item.Summary.NoStatement);
Assert.Equal(now, item.LatestModifiedAt);
Assert.Equal("hash-new", item.LastEvidenceHash);
Assert.Equal(new[] { "oracle", "ubuntu" }, item.Sources);
Assert.Equal(new[] { "ubuntu" }, item.Sources);
}
private static VexObservation CreateObservation(

View File

@@ -2,6 +2,7 @@ using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Contracts;
namespace StellaOps.Excititor.WebService.Tests;

View File

@@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "..\Notify\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{6D2D2F1F-45AA-4F52-AD1B-1F7562F7C714}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo", "..\Notify\__Libraries\StellaOps.Notify.Storage.Mongo\StellaOps.Notify.Storage.Mongo.csproj", "{6F58764A-34A9-4880-BF08-C7FB61B5819B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "..\Notify\__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{E61AA8CA-29C2-4BEB-B53B-36B7DE31E9AE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notifier.WebService", "StellaOps.Notifier\StellaOps.Notifier.WebService\StellaOps.Notifier.WebService.csproj", "{F6252853-A408-4658-9006-5DDF140A536A}"
@@ -77,18 +75,6 @@ Global
{6D2D2F1F-45AA-4F52-AD1B-1F7562F7C714}.Release|x64.Build.0 = Release|Any CPU
{6D2D2F1F-45AA-4F52-AD1B-1F7562F7C714}.Release|x86.ActiveCfg = Release|Any CPU
{6D2D2F1F-45AA-4F52-AD1B-1F7562F7C714}.Release|x86.Build.0 = Release|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Debug|x64.ActiveCfg = Debug|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Debug|x64.Build.0 = Debug|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Debug|x86.ActiveCfg = Debug|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Debug|x86.Build.0 = Debug|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Release|Any CPU.Build.0 = Release|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Release|x64.ActiveCfg = Release|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Release|x64.Build.0 = Release|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Release|x86.ActiveCfg = Release|Any CPU
{6F58764A-34A9-4880-BF08-C7FB61B5819B}.Release|x86.Build.0 = Release|Any CPU
{E61AA8CA-29C2-4BEB-B53B-36B7DE31E9AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E61AA8CA-29C2-4BEB-B53B-36B7DE31E9AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E61AA8CA-29C2-4BEB-B53B-36B7DE31E9AE}.Debug|x64.ActiveCfg = Debug|Any CPU

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />

View File

@@ -25,27 +25,4 @@ public interface INotifyChannelAdapter
CancellationToken cancellationToken);
}
/// <summary>
/// Result of a channel dispatch attempt.
/// </summary>
public sealed record ChannelDispatchResult
{
public required bool Success { get; init; }
public int? StatusCode { get; init; }
public string? Reason { get; init; }
public bool ShouldRetry { get; init; }
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
{
Success = true,
StatusCode = statusCode
};
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
{
Success = false,
StatusCode = statusCode,
Reason = reason,
ShouldRetry = shouldRetry
};
}
// Note: ChannelDispatchResult is defined in IChannelAdapter.cs

View File

@@ -1,221 +0,0 @@
using Cronos;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Default implementation of quiet hours evaluator using cron expressions.
/// </summary>
public sealed class DefaultQuietHoursEvaluator : IQuietHoursEvaluator
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultQuietHoursEvaluator> _logger;
private readonly INotifyQuietHoursRepository? _quietHoursRepository;
private readonly INotifyMaintenanceWindowRepository? _maintenanceWindowRepository;
private readonly INotifyOperatorOverrideRepository? _operatorOverrideRepository;
// In-memory fallback for testing
private readonly List<NotifyQuietHoursSchedule> _schedules = [];
private readonly List<NotifyMaintenanceWindow> _maintenanceWindows = [];
public DefaultQuietHoursEvaluator(
TimeProvider timeProvider,
ILogger<DefaultQuietHoursEvaluator> logger,
INotifyQuietHoursRepository? quietHoursRepository = null,
INotifyMaintenanceWindowRepository? maintenanceWindowRepository = null,
INotifyOperatorOverrideRepository? operatorOverrideRepository = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_quietHoursRepository = quietHoursRepository;
_maintenanceWindowRepository = maintenanceWindowRepository;
_operatorOverrideRepository = operatorOverrideRepository;
}
public async Task<QuietHoursCheckResult> IsInQuietHoursAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
// Check for active bypass override
if (_operatorOverrideRepository is not null)
{
var overrides = await _operatorOverrideRepository.ListActiveAsync(
tenantId, now, NotifyOverrideType.BypassQuietHours, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (overrides.Count > 0)
{
_logger.LogDebug(
"Quiet hours bypassed by operator override for tenant {TenantId}: override={OverrideId}",
tenantId, overrides[0].OverrideId);
return new QuietHoursCheckResult
{
IsInQuietHours = false,
Reason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
};
}
}
// Find applicable schedules for this tenant
IEnumerable<NotifyQuietHoursSchedule> applicableSchedules;
if (_quietHoursRepository is not null)
{
var schedules = await _quietHoursRepository.ListEnabledAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
applicableSchedules = schedules;
}
else
{
applicableSchedules = _schedules
.Where(s => s.TenantId == tenantId && s.Enabled)
.Where(s => channelId is null || s.ChannelId is null || s.ChannelId == channelId);
}
foreach (var schedule in applicableSchedules)
{
if (IsInSchedule(schedule, now, out var endsAt))
{
_logger.LogDebug(
"Quiet hours active for tenant {TenantId}: schedule={ScheduleId}, endsAt={EndsAt}",
tenantId, schedule.ScheduleId, endsAt);
return new QuietHoursCheckResult
{
IsInQuietHours = true,
QuietHoursScheduleId = schedule.ScheduleId,
QuietHoursEndsAt = endsAt,
Reason = $"Quiet hours: {schedule.Name}"
};
}
}
return new QuietHoursCheckResult
{
IsInQuietHours = false
};
}
public async Task<MaintenanceCheckResult> IsInMaintenanceAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
// Check for active bypass override
if (_operatorOverrideRepository is not null)
{
var overrides = await _operatorOverrideRepository.ListActiveAsync(
tenantId, now, NotifyOverrideType.BypassMaintenance, cancellationToken: cancellationToken).ConfigureAwait(false);
if (overrides.Count > 0)
{
_logger.LogDebug(
"Maintenance window bypassed by operator override for tenant {TenantId}: override={OverrideId}",
tenantId, overrides[0].OverrideId);
return new MaintenanceCheckResult
{
IsInMaintenance = false,
MaintenanceReason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
};
}
}
// Find active maintenance windows
NotifyMaintenanceWindow? activeWindow;
if (_maintenanceWindowRepository is not null)
{
var windows = await _maintenanceWindowRepository.GetActiveAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
activeWindow = windows.FirstOrDefault();
}
else
{
activeWindow = _maintenanceWindows
.Where(w => w.TenantId == tenantId && w.SuppressNotifications)
.FirstOrDefault(w => w.IsActiveAt(now));
}
if (activeWindow is not null)
{
_logger.LogDebug(
"Maintenance window active for tenant {TenantId}: window={WindowId}, endsAt={EndsAt}",
tenantId, activeWindow.WindowId, activeWindow.EndsAt);
return new MaintenanceCheckResult
{
IsInMaintenance = true,
MaintenanceWindowId = activeWindow.WindowId,
MaintenanceEndsAt = activeWindow.EndsAt,
MaintenanceReason = activeWindow.Reason
};
}
return new MaintenanceCheckResult
{
IsInMaintenance = false
};
}
/// <summary>
/// Adds a quiet hours schedule (for configuration/testing).
/// </summary>
public void AddSchedule(NotifyQuietHoursSchedule schedule)
{
ArgumentNullException.ThrowIfNull(schedule);
_schedules.Add(schedule);
}
/// <summary>
/// Adds a maintenance window (for configuration/testing).
/// </summary>
public void AddMaintenanceWindow(NotifyMaintenanceWindow window)
{
ArgumentNullException.ThrowIfNull(window);
_maintenanceWindows.Add(window);
}
private bool IsInSchedule(NotifyQuietHoursSchedule schedule, DateTimeOffset now, out DateTimeOffset? endsAt)
{
endsAt = null;
try
{
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZone);
var localNow = TimeZoneInfo.ConvertTime(now, timeZone);
var cron = CronExpression.Parse(schedule.CronExpression);
// Look back for the most recent occurrence
var searchStart = localNow.AddDays(-1);
var lastOccurrence = cron.GetNextOccurrence(searchStart.DateTime, timeZone, inclusive: true);
if (lastOccurrence.HasValue)
{
var occurrenceOffset = new DateTimeOffset(lastOccurrence.Value, timeZone.GetUtcOffset(lastOccurrence.Value));
var windowEnd = occurrenceOffset.Add(schedule.Duration);
if (now >= occurrenceOffset && now < windowEnd)
{
endsAt = windowEnd;
return true;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to evaluate quiet hours schedule {ScheduleId} for tenant {TenantId}",
schedule.ScheduleId, schedule.TenantId);
}
return false;
}
}

View File

@@ -1,41 +0,0 @@
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Throttling service for rate-limiting notifications.
/// </summary>
public interface INotifyThrottler
{
/// <summary>
/// Checks if a notification should be throttled based on the key and window.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="throttleKey">The unique key for throttling (e.g., action + correlation key).</param>
/// <param name="window">The throttle window duration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if throttled (should not send), false if allowed.</returns>
Task<bool> IsThrottledAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a notification as sent, establishing the throttle marker.
/// </summary>
Task RecordSentAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a throttle check with additional context.
/// </summary>
public sealed record ThrottleCheckResult
{
public required bool IsThrottled { get; init; }
public DateTimeOffset? ThrottledUntil { get; init; }
public DateTimeOffset? LastSentAt { get; init; }
public int SuppressedCount { get; init; }
}

View File

@@ -1,44 +0,0 @@
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Evaluates whether notifications should be suppressed due to quiet hours or maintenance windows.
/// </summary>
public interface IQuietHoursEvaluator
{
/// <summary>
/// Checks if the current time falls within a quiet hours period for the tenant.
/// </summary>
Task<QuietHoursCheckResult> IsInQuietHoursAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if notifications should be suppressed due to an active maintenance window.
/// </summary>
Task<MaintenanceCheckResult> IsInMaintenanceAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a quiet hours check.
/// </summary>
public sealed record QuietHoursCheckResult
{
public required bool IsInQuietHours { get; init; }
public string? QuietHoursScheduleId { get; init; }
public DateTimeOffset? QuietHoursEndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Result of a maintenance window check.
/// </summary>
public sealed record MaintenanceCheckResult
{
public required bool IsInMaintenance { get; init; }
public string? MaintenanceWindowId { get; init; }
public DateTimeOffset? MaintenanceEndsAt { get; init; }
public string? MaintenanceReason { get; init; }
}

View File

@@ -1,456 +0,0 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notifier.Worker.Observability;
/// <summary>
/// Manages data retention policies for notifications and related data.
/// </summary>
public interface IRetentionPolicyService
{
/// <summary>
/// Gets all retention policies for a tenant.
/// </summary>
Task<IReadOnlyList<RetentionPolicy>> GetPoliciesAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific retention policy.
/// </summary>
Task<RetentionPolicy?> GetPolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a retention policy.
/// </summary>
Task<RetentionPolicy> UpsertPolicyAsync(RetentionPolicy policy, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a retention policy.
/// </summary>
Task<bool> DeletePolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Applies retention policies and purges old data.
/// </summary>
Task<RetentionResult> ApplyAsync(string? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Gets retention statistics.
/// </summary>
Task<RetentionStats> GetStatsAsync(string? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Previews what would be deleted by retention policies.
/// </summary>
Task<RetentionPreview> PreviewAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// A data retention policy.
/// </summary>
public sealed record RetentionPolicy
{
public required string PolicyId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required RetentionDataType DataType { get; init; }
public required TimeSpan RetentionPeriod { get; init; }
public RetentionAction Action { get; init; } = RetentionAction.Delete;
public string? ArchiveDestination { get; init; }
public bool Enabled { get; init; } = true;
public IReadOnlyList<string>? ChannelTypes { get; init; }
public IReadOnlyList<string>? EventKinds { get; init; }
public int? MinimumCount { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? LastAppliedAt { get; init; }
}
/// <summary>
/// Type of data subject to retention.
/// </summary>
public enum RetentionDataType
{
Deliveries,
DeadLetters,
Incidents,
AuditLogs,
Metrics,
Templates,
EscalationHistory,
DigestHistory,
InboxNotifications
}
/// <summary>
/// Action to take when retention period expires.
/// </summary>
public enum RetentionAction
{
Delete,
Archive,
Anonymize
}
/// <summary>
/// Result of applying retention policies.
/// </summary>
public sealed record RetentionResult
{
public DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public int PoliciesApplied { get; init; }
public int TotalDeleted { get; init; }
public int TotalArchived { get; init; }
public int TotalAnonymized { get; init; }
public TimeSpan Duration { get; init; }
public IReadOnlyList<RetentionPolicyResult> PolicyResults { get; init; } = [];
public IReadOnlyList<string> Errors { get; init; } = [];
}
/// <summary>
/// Result of applying a single retention policy.
/// </summary>
public sealed record RetentionPolicyResult
{
public required string PolicyId { get; init; }
public required string PolicyName { get; init; }
public required RetentionDataType DataType { get; init; }
public int AffectedCount { get; init; }
public RetentionAction ActionTaken { get; init; }
public bool Success { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Statistics about retention.
/// </summary>
public sealed record RetentionStats
{
public DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public int TotalPolicies { get; init; }
public int EnabledPolicies { get; init; }
public int DisabledPolicies { get; init; }
public long TotalDeletedAllTime { get; init; }
public long TotalArchivedAllTime { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public DateTimeOffset? NextScheduledRun { get; init; }
public IReadOnlyDictionary<RetentionDataType, DataTypeStats> ByDataType { get; init; } = new Dictionary<RetentionDataType, DataTypeStats>();
}
/// <summary>
/// Statistics for a specific data type.
/// </summary>
public sealed record DataTypeStats
{
public required RetentionDataType DataType { get; init; }
public long CurrentCount { get; init; }
public DateTimeOffset? OldestRecord { get; init; }
public long DeletedCount { get; init; }
public long ArchivedCount { get; init; }
}
/// <summary>
/// Preview of what retention would delete.
/// </summary>
public sealed record RetentionPreview
{
public DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public int TotalToDelete { get; init; }
public int TotalToArchive { get; init; }
public int TotalToAnonymize { get; init; }
public IReadOnlyList<RetentionPreviewItem> Items { get; init; } = [];
}
/// <summary>
/// Preview item for a single policy.
/// </summary>
public sealed record RetentionPreviewItem
{
public required string PolicyId { get; init; }
public required string PolicyName { get; init; }
public required RetentionDataType DataType { get; init; }
public int AffectedCount { get; init; }
public RetentionAction Action { get; init; }
public DateTimeOffset? OldestAffected { get; init; }
public DateTimeOffset? NewestAffected { get; init; }
}
/// <summary>
/// Options for retention service.
/// </summary>
public sealed class RetentionOptions
{
public const string SectionName = "Notifier:Observability:Retention";
public bool Enabled { get; set; } = true;
public TimeSpan DefaultRetentionPeriod { get; set; } = TimeSpan.FromDays(90);
public TimeSpan MinimumRetentionPeriod { get; set; } = TimeSpan.FromDays(1);
public TimeSpan MaximumRetentionPeriod { get; set; } = TimeSpan.FromDays(365 * 7);
public bool AutoRun { get; set; } = true;
public TimeSpan RunInterval { get; set; } = TimeSpan.FromHours(24);
public TimeSpan RunTime { get; set; } = TimeSpan.FromHours(3);
public int BatchSize { get; set; } = 1000;
public bool DryRunByDefault { get; set; }
}
/// <summary>
/// In-memory implementation of retention policy service.
/// </summary>
public sealed class InMemoryRetentionPolicyService : IRetentionPolicyService
{
private readonly ConcurrentDictionary<string, List<RetentionPolicy>> _policies = new();
private readonly ConcurrentDictionary<string, RetentionStats> _stats = new();
private readonly RetentionOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryRetentionPolicyService> _logger;
public InMemoryRetentionPolicyService(
IOptions<RetentionOptions> options,
TimeProvider timeProvider,
ILogger<InMemoryRetentionPolicyService> logger)
{
_options = options?.Value ?? new RetentionOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<IReadOnlyList<RetentionPolicy>> GetPoliciesAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies))
return Task.FromResult<IReadOnlyList<RetentionPolicy>>([]);
return Task.FromResult<IReadOnlyList<RetentionPolicy>>(policies.ToList());
}
public Task<RetentionPolicy?> GetPolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies))
return Task.FromResult<RetentionPolicy?>(null);
return Task.FromResult(policies.FirstOrDefault(p => p.PolicyId == policyId));
}
public Task<RetentionPolicy> UpsertPolicyAsync(RetentionPolicy policy, CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var list = _policies.GetOrAdd(policy.TenantId, _ => []);
lock (list)
{
var index = list.FindIndex(p => p.PolicyId == policy.PolicyId);
var updated = policy with { UpdatedAt = now, CreatedAt = index < 0 ? now : list[index].CreatedAt };
if (index >= 0) list[index] = updated;
else list.Add(updated);
_logger.LogInformation("Upserted retention policy {PolicyId} for tenant {TenantId}", policy.PolicyId, policy.TenantId);
return Task.FromResult(updated);
}
}
public Task<bool> DeletePolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies)) return Task.FromResult(false);
lock (policies)
{
var removed = policies.RemoveAll(p => p.PolicyId == policyId) > 0;
if (removed) _logger.LogInformation("Deleted retention policy {PolicyId} for tenant {TenantId}", policyId, tenantId);
return Task.FromResult(removed);
}
}
public Task<RetentionResult> ApplyAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
var policyResults = new List<RetentionPolicyResult>();
var errors = new List<string>();
var totalDeleted = 0;
var totalArchived = 0;
var totalAnonymized = 0;
var tenantsToProcess = tenantId is not null ? [tenantId] : _policies.Keys.ToList();
foreach (var t in tenantsToProcess)
{
if (!_policies.TryGetValue(t, out var policies)) continue;
foreach (var policy in policies.Where(p => p.Enabled))
{
try
{
var affectedCount = SimulateRetention(policy);
var result = new RetentionPolicyResult
{
PolicyId = policy.PolicyId,
PolicyName = policy.Name,
DataType = policy.DataType,
AffectedCount = affectedCount,
ActionTaken = policy.Action,
Success = true
};
policyResults.Add(result);
switch (policy.Action)
{
case RetentionAction.Delete: totalDeleted += affectedCount; break;
case RetentionAction.Archive: totalArchived += affectedCount; break;
case RetentionAction.Anonymize: totalAnonymized += affectedCount; break;
}
// Update last applied time
lock (policies)
{
var idx = policies.FindIndex(p => p.PolicyId == policy.PolicyId);
if (idx >= 0) policies[idx] = policy with { LastAppliedAt = _timeProvider.GetUtcNow() };
}
}
catch (Exception ex)
{
errors.Add($"Policy {policy.PolicyId}: {ex.Message}");
policyResults.Add(new RetentionPolicyResult
{
PolicyId = policy.PolicyId,
PolicyName = policy.Name,
DataType = policy.DataType,
Success = false,
Error = ex.Message
});
}
}
}
var endTime = _timeProvider.GetUtcNow();
_logger.LogInformation("Applied retention policies: {Deleted} deleted, {Archived} archived, {Anonymized} anonymized", totalDeleted, totalArchived, totalAnonymized);
return Task.FromResult(new RetentionResult
{
Timestamp = endTime,
TenantId = tenantId,
PoliciesApplied = policyResults.Count(r => r.Success),
TotalDeleted = totalDeleted,
TotalArchived = totalArchived,
TotalAnonymized = totalAnonymized,
Duration = endTime - startTime,
PolicyResults = policyResults,
Errors = errors
});
}
public Task<RetentionStats> GetStatsAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var allPolicies = tenantId is not null
? (_policies.TryGetValue(tenantId, out var p) ? p : [])
: _policies.Values.SelectMany(v => v).ToList();
var byDataType = Enum.GetValues<RetentionDataType>()
.ToDictionary(dt => dt, dt => new DataTypeStats { DataType = dt, CurrentCount = 0, DeletedCount = 0, ArchivedCount = 0 });
return Task.FromResult(new RetentionStats
{
Timestamp = _timeProvider.GetUtcNow(),
TenantId = tenantId,
TotalPolicies = allPolicies.Count,
EnabledPolicies = allPolicies.Count(p => p.Enabled),
DisabledPolicies = allPolicies.Count(p => !p.Enabled),
LastRunAt = allPolicies.Max(p => p.LastAppliedAt),
ByDataType = byDataType
});
}
public Task<RetentionPreview> PreviewAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies))
return Task.FromResult(new RetentionPreview { Timestamp = _timeProvider.GetUtcNow(), TenantId = tenantId });
var items = policies.Where(p => p.Enabled).Select(p => new RetentionPreviewItem
{
PolicyId = p.PolicyId,
PolicyName = p.Name,
DataType = p.DataType,
AffectedCount = SimulateRetention(p),
Action = p.Action
}).ToList();
return Task.FromResult(new RetentionPreview
{
Timestamp = _timeProvider.GetUtcNow(),
TenantId = tenantId,
TotalToDelete = items.Where(i => i.Action == RetentionAction.Delete).Sum(i => i.AffectedCount),
TotalToArchive = items.Where(i => i.Action == RetentionAction.Archive).Sum(i => i.AffectedCount),
TotalToAnonymize = items.Where(i => i.Action == RetentionAction.Anonymize).Sum(i => i.AffectedCount),
Items = items
});
}
private int SimulateRetention(RetentionPolicy policy)
{
// In production, this would query actual data stores
// For simulation, return a random count based on retention period
var daysFactor = (int)policy.RetentionPeriod.TotalDays;
return Math.Max(0, 100 - daysFactor);
}
}
/// <summary>
/// Background service that runs retention policies on schedule.
/// </summary>
public sealed class RetentionPolicyRunner : BackgroundService
{
private readonly IRetentionPolicyService _retentionService;
private readonly RetentionOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RetentionPolicyRunner> _logger;
public RetentionPolicyRunner(
IRetentionPolicyService retentionService,
IOptions<RetentionOptions> options,
TimeProvider timeProvider,
ILogger<RetentionPolicyRunner> logger)
{
_retentionService = retentionService ?? throw new ArgumentNullException(nameof(retentionService));
_options = options?.Value ?? new RetentionOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled || !_options.AutoRun)
{
_logger.LogInformation("Retention policy runner is disabled");
return;
}
_logger.LogInformation("Retention policy runner started with interval {Interval}", _options.RunInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = _timeProvider.GetUtcNow();
var nextRun = now.Date.Add(_options.RunTime);
if (nextRun <= now) nextRun = nextRun.AddDays(1);
var delay = nextRun - now;
if (delay > _options.RunInterval) delay = _options.RunInterval;
await Task.Delay(delay, stoppingToken);
_logger.LogInformation("Running scheduled retention policy application");
var result = await _retentionService.ApplyAsync(cancellationToken: stoppingToken);
_logger.LogInformation("Retention completed: {Deleted} deleted, {Archived} archived in {Duration}ms",
result.TotalDeleted, result.TotalArchived, result.Duration.TotalMilliseconds);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running retention policies");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
}

View File

@@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />

View File

@@ -19,8 +19,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo", "__Libraries\StellaOps.Notify.Storage.Mongo\StellaOps.Notify.Storage.Mongo.csproj", "{BD147625-3614-49BB-B484-01200F28FF8B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{EFF370F5-788E-4E39-8D80-1DFC6563E45C}"
@@ -55,8 +53,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{84451047-1B04-42D1-9C02-762564CC2B40}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo.Tests", "__Tests\StellaOps.Notify.Storage.Mongo.Tests\StellaOps.Notify.Storage.Mongo.Tests.csproj", "{C63A47A3-18A6-4251-95A7-392EB58D7B87}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{EDAF907C-18A1-4099-9D3B-169B38400420}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker.Tests", "__Tests\StellaOps.Notify.Worker.Tests\StellaOps.Notify.Worker.Tests.csproj", "{66801106-E70A-4D33-8A08-A46C08902603}"
@@ -163,18 +159,6 @@ Global
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.Build.0 = Release|Any CPU
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.ActiveCfg = Release|Any CPU
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.Build.0 = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.ActiveCfg = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.Build.0 = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.ActiveCfg = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.Build.0 = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.Build.0 = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.ActiveCfg = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.Build.0 = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.ActiveCfg = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.Build.0 = Release|Any CPU
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -367,18 +351,6 @@ Global
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.Build.0 = Release|Any CPU
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.ActiveCfg = Release|Any CPU
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.Build.0 = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.ActiveCfg = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.Build.0 = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.ActiveCfg = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.Build.0 = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.Build.0 = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.ActiveCfg = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.Build.0 = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.ActiveCfg = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.Build.0 = Release|Any CPU
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -457,7 +429,6 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{BD147625-3614-49BB-B484-01200F28FF8B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{8048E985-85DE-4B05-AB76-67C436D6516F} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
@@ -471,7 +442,6 @@ Global
{DE4E8371-7933-4D96-9023-36F5D2DDFC56} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{08428B42-D650-430E-9E51-8A3B18B4C984} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{84451047-1B04-42D1-9C02-762564CC2B40} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{C63A47A3-18A6-4251-95A7-392EB58D7B87} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{EDAF907C-18A1-4099-9D3B-169B38400420} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{66801106-E70A-4D33-8A08-A46C08902603} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{8957A93C-F7E1-41C0-89C4-3FC547621B91} = {41F15E67-7190-CF23-3BC4-77E87134CADD}

View File

@@ -0,0 +1,232 @@
namespace StellaOps.Notify.Storage.Mongo.Documents;
/// <summary>
/// Represents a notification channel document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyChannelDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ChannelType { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string Config { get; set; } = "{}";
public string? Credentials { get; set; }
public string Metadata { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
/// <summary>
/// Represents a notification rule document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyRuleDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool Enabled { get; set; } = true;
public int Priority { get; set; }
public string EventFilter { get; set; } = "{}";
public string? ChannelId { get; set; }
public string? TemplateId { get; set; }
public string? DigestConfig { get; set; }
public string? EscalationPolicyId { get; set; }
public string Metadata { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
/// <summary>
/// Represents a notification template document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyTemplateDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public string Format { get; set; } = "text";
public string? ChannelType { get; set; }
public string Metadata { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
/// <summary>
/// Represents a notification delivery document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyDeliveryDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? RuleId { get; set; }
public string? ChannelId { get; set; }
public string? TemplateId { get; set; }
public string Status { get; set; } = "pending";
public string? Error { get; set; }
public string Payload { get; set; } = "{}";
public string? RenderedSubject { get; set; }
public string? RenderedBody { get; set; }
public int RetryCount { get; set; }
public DateTimeOffset? NextRetryAt { get; set; }
public DateTimeOffset? SentAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a notification digest document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyDigestDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? RuleId { get; set; }
public string DigestKey { get; set; } = string.Empty;
public DateTimeOffset WindowStart { get; set; }
public DateTimeOffset WindowEnd { get; set; }
public List<string> EventIds { get; set; } = new();
public int EventCount { get; set; }
public string Status { get; set; } = "collecting";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a notification audit document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyAuditDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? DeliveryId { get; set; }
public string Action { get; set; } = string.Empty;
public string? Actor { get; set; }
public string? Details { get; set; }
public DateTimeOffset Timestamp { get; set; }
}
/// <summary>
/// Represents an escalation policy document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyEscalationPolicyDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public List<NotifyEscalationStep> Steps { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an escalation step.
/// </summary>
public sealed class NotifyEscalationStep
{
public int Order { get; set; }
public TimeSpan Delay { get; set; }
public string? ChannelId { get; set; }
public List<string> Targets { get; set; } = new();
}
/// <summary>
/// Represents escalation state document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyEscalationStateDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? DeliveryId { get; set; }
public string? PolicyId { get; set; }
public int CurrentStep { get; set; }
public string Status { get; set; } = "active";
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
public DateTimeOffset? NextEscalationAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an on-call schedule document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyOnCallScheduleDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? TimeZone { get; set; }
public List<NotifyOnCallRotation> Rotations { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an on-call rotation.
/// </summary>
public sealed class NotifyOnCallRotation
{
public string? UserId { get; set; }
public DateTimeOffset Start { get; set; }
public DateTimeOffset End { get; set; }
}
/// <summary>
/// Represents a quiet hours configuration document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyQuietHoursDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? TimeZone { get; set; }
public TimeSpan StartTime { get; set; }
public TimeSpan EndTime { get; set; }
public List<DayOfWeek> DaysOfWeek { get; set; } = new();
public bool Enabled { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a maintenance window document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyMaintenanceWindowDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTimeOffset StartAt { get; set; }
public DateTimeOffset EndAt { get; set; }
public List<string>? AffectedServices { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an inbox message document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyInboxDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public string? DeliveryId { get; set; }
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool Read { get; set; }
public DateTimeOffset? ReadAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -1,945 +0,0 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Documents;
public sealed class NotifyAuditEntryDocument
{
public required string TenantId { get; init; }
public required string Action { get; init; }
public string? Actor { get; init; }
public string? EntityId { get; init; }
public string? EntityType { get; init; }
public string? CorrelationId { get; init; }
public JsonObject? Payload { get; init; }
public DateTimeOffset Timestamp { get; init; }
}
public sealed class NotifyDigestDocument
{
public required string TenantId { get; init; }
public required string ActionKey { get; init; }
public string? Content { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed class PackApprovalDocument
{
public required string TenantId { get; init; }
public required Guid EventId { get; init; }
public required string PackId { get; init; }
public string? Kind { get; init; }
public string? Decision { get; init; }
public string? Actor { get; init; }
public DateTimeOffset? IssuedAt { get; init; }
public string? PolicyId { get; init; }
public string? PolicyVersion { get; init; }
public string? ResumeToken { get; init; }
public string? Summary { get; init; }
public IDictionary<string, string>? Labels { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed class NotifyInboxMessage
{
public required string MessageId { get; init; }
public required string TenantId { get; init; }
public required string UserId { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public string? Summary { get; init; }
public string? Category { get; init; }
public int Priority { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ReadAt { get; set; }
public string? SourceChannel { get; init; }
public string? DeliveryId { get; init; }
}
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyMongoInitializer
{
Task EnsureIndexesAsync(CancellationToken cancellationToken = default);
}
public interface INotifyMongoMigration { }
public interface INotifyMongoMigrationRunner { }
public interface INotifyRuleRepository
{
Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default);
Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
}
public interface INotifyChannelRepository
{
Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default);
Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
}
public interface INotifyTemplateRepository
{
Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default);
Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
}
public interface INotifyDeliveryRepository
{
Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default);
Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}
public sealed record NotifyDeliveryQueryResult(IReadOnlyList<NotifyDelivery> Items, string? ContinuationToken);
public interface INotifyDigestRepository
{
Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default);
Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
}
public interface INotifyLockRepository
{
Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default);
}
public interface INotifyAuditRepository
{
Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default);
Task AppendAsync(string tenantId, string action, IReadOnlyDictionary<string, string> payload, string? actor = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default);
}
public interface INotifyPackApprovalRepository
{
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
bool Exists(string tenantId, Guid eventId, string packId);
}
public interface INotifyQuietHoursRepository
{
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(string tenantId, string? channelId = null, CancellationToken cancellationToken = default);
}
public interface INotifyMaintenanceWindowRepository
{
Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(string tenantId, DateTimeOffset timestamp, CancellationToken cancellationToken = default);
}
public interface INotifyOperatorOverrideRepository
{
Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
string tenantId,
DateTimeOffset asOf,
NotifyOverrideType? type = null,
string? channelId = null,
CancellationToken cancellationToken = default);
}
public interface INotifyThrottleConfigRepository
{
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
}
public interface INotifyLocalizationRepository
{
Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default);
Task<NotifyLocalizationBundle?> GetDefaultAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default);
}
public interface INotifyEscalationPolicyRepository
{
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicy?> GetAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyEscalationPolicy policy, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
}
public interface INotifyEscalationStateRepository
{
Task<NotifyEscalationState?> GetAsync(string tenantId, string stateId, CancellationToken cancellationToken = default);
Task<NotifyEscalationState?> GetByIncidentAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(string tenantId, DateTimeOffset asOf, int batchSize, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyEscalationState state, CancellationToken cancellationToken = default);
Task AcknowledgeAsync(string tenantId, string stateId, string acknowledgedBy, DateTimeOffset acknowledgedAt, CancellationToken cancellationToken = default);
Task ResolveAsync(string tenantId, string stateId, string resolvedBy, DateTimeOffset resolvedAt, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string stateId, CancellationToken cancellationToken = default);
}
public interface INotifyOnCallScheduleRepository
{
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyOnCallSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyOnCallSchedule schedule, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
}
public interface INotifyInboxRepository
{
Task StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
Task<NotifyInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
}
internal sealed class InMemoryRuleRepository : INotifyRuleRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> _rules = new(StringComparer.Ordinal);
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rule);
var tenantRules = _rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
tenantRules[rule.RuleId] = rule;
return Task.CompletedTask;
}
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules) && rules.TryGetValue(ruleId, out var rule))
{
return Task.FromResult<NotifyRule?>(rule);
}
return Task.FromResult<NotifyRule?>(null);
}
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules))
{
return Task.FromResult<IReadOnlyList<NotifyRule>>(rules.Values.ToArray());
}
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
}
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules))
{
rules.TryRemove(ruleId, out _);
}
return Task.CompletedTask;
}
}
internal sealed class InMemoryChannelRepository : INotifyChannelRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> _channels = new(StringComparer.Ordinal);
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channel);
var map = _channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
map[channel.ChannelId] = channel;
return Task.CompletedTask;
}
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
{
return Task.FromResult<NotifyChannel?>(channel);
}
return Task.FromResult<NotifyChannel?>(null);
}
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.ToArray());
}
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
}
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map))
{
map.TryRemove(channelId, out _);
}
return Task.CompletedTask;
}
}
internal sealed class InMemoryTemplateRepository : INotifyTemplateRepository
{
private readonly ConcurrentDictionary<(string TenantId, string TemplateId), NotifyTemplate> _templates = new();
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
_templates[(template.TenantId, template.TemplateId)] = template;
return Task.CompletedTask;
}
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
_templates.TryGetValue((tenantId, templateId), out var tpl);
return Task.FromResult(tpl);
}
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var list = _templates.Where(kv => kv.Key.TenantId == tenantId).Select(kv => kv.Value).ToList();
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(list);
}
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
_templates.TryRemove((tenantId, templateId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
lock (list)
{
list.Add(delivery);
}
return Task.CompletedTask;
}
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
lock (list)
{
var index = list.FindIndex(existing => existing.DeliveryId == delivery.DeliveryId);
if (index >= 0)
{
list[index] = delivery;
}
else
{
list.Add(delivery);
}
}
return Task.CompletedTask;
}
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
{
if (_deliveries.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<NotifyDelivery?>(list.FirstOrDefault(delivery => delivery.DeliveryId == deliveryId));
}
}
return Task.FromResult<NotifyDelivery?>(null);
}
public Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default)
{
if (_deliveries.TryGetValue(tenantId, out var list))
{
lock (list)
{
var items = list
.Where(d => (!since.HasValue || d.CreatedAt >= since) &&
(string.IsNullOrWhiteSpace(status) || string.Equals(d.Status.ToString(), status, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(d => d.CreatedAt)
.Take(limit ?? 50)
.ToArray();
return Task.FromResult(new NotifyDeliveryQueryResult(items, null));
}
}
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
}
}
internal sealed class InMemoryDigestRepository : INotifyDigestRepository
{
private readonly ConcurrentDictionary<(string TenantId, string ActionKey), NotifyDigestDocument> _digests = new();
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
_digests.TryGetValue((tenantId, actionKey), out var doc);
return Task.FromResult(doc);
}
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
{
_digests[(document.TenantId, document.ActionKey)] = document;
return Task.CompletedTask;
}
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
_digests.TryRemove((tenantId, actionKey), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly object _sync = new();
private readonly Dictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset Expiry)> _locks = new();
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(resource);
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
lock (_sync)
{
var key = (tenantId, resource);
var now = DateTimeOffset.UtcNow;
if (_locks.TryGetValue(key, out var existing) && existing.Expiry > now)
{
return Task.FromResult(false);
}
_locks[key] = (owner, now + ttl);
return Task.FromResult(true);
}
}
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var key = (tenantId, resource);
_locks.Remove(key);
return Task.CompletedTask;
}
}
}
internal sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly ConcurrentDictionary<string, List<NotifyAuditEntryDocument>> _entries = new(StringComparer.Ordinal);
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
var list = _entries.GetOrAdd(entry.TenantId, _ => new List<NotifyAuditEntryDocument>());
lock (list)
{
list.Add(entry);
}
return Task.CompletedTask;
}
public Task AppendAsync(string tenantId, string action, IReadOnlyDictionary<string, string> payload, string? actor = null, CancellationToken cancellationToken = default)
{
var entry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Action = action,
Actor = actor,
EntityType = "audit",
Timestamp = DateTimeOffset.UtcNow,
Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
};
return AppendAsync(entry, cancellationToken);
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
if (_entries.TryGetValue(tenantId, out var list))
{
lock (list)
{
var items = list
.Where(e => !since.HasValue || e.Timestamp >= since.Value)
.OrderByDescending(e => e.Timestamp)
.ToList();
if (limit is > 0)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
}
}
internal sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _records = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_records[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
public bool Exists(string tenantId, Guid eventId, string packId)
=> _records.ContainsKey((tenantId, eventId, packId));
}
internal sealed class InMemoryQuietHoursRepository : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, List<NotifyQuietHoursSchedule>> _schedules = new(StringComparer.Ordinal);
public Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(string tenantId, string? channelId = null, CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(tenantId, out var list))
{
var filtered = list
.Where(s => s.Enabled)
.Where(s => channelId is null || s.ChannelId is null || s.ChannelId == channelId)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(filtered);
}
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(Array.Empty<NotifyQuietHoursSchedule>());
}
public void Seed(string tenantId, params NotifyQuietHoursSchedule[] schedules)
{
var list = _schedules.GetOrAdd(tenantId, _ => new List<NotifyQuietHoursSchedule>());
lock (list)
{
list.AddRange(schedules);
}
}
}
internal sealed class InMemoryMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, List<NotifyMaintenanceWindow>> _windows = new(StringComparer.Ordinal);
public Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(string tenantId, DateTimeOffset timestamp, CancellationToken cancellationToken = default)
{
if (_windows.TryGetValue(tenantId, out var list))
{
var active = list.Where(w => w.IsActiveAt(timestamp)).ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(active);
}
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(Array.Empty<NotifyMaintenanceWindow>());
}
public void Seed(string tenantId, params NotifyMaintenanceWindow[] windows)
{
var list = _windows.GetOrAdd(tenantId, _ => new List<NotifyMaintenanceWindow>());
lock (list)
{
list.AddRange(windows);
}
}
}
internal sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverrideRepository
{
private readonly ConcurrentDictionary<string, List<NotifyOperatorOverride>> _overrides = new(StringComparer.Ordinal);
public Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
string tenantId,
DateTimeOffset asOf,
NotifyOverrideType? type = null,
string? channelId = null,
CancellationToken cancellationToken = default)
{
if (_overrides.TryGetValue(tenantId, out var list))
{
var items = list
.Where(o => o.IsActiveAt(asOf))
.Where(o => type is null || o.Type == type)
.Where(o => channelId is null || o.ChannelId is null || o.ChannelId == channelId)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(items);
}
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(Array.Empty<NotifyOperatorOverride>());
}
public void Seed(string tenantId, params NotifyOperatorOverride[] overrides)
{
var list = _overrides.GetOrAdd(tenantId, _ => new List<NotifyOperatorOverride>());
lock (list)
{
list.AddRange(overrides);
}
}
}
internal sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepository
{
private readonly ConcurrentDictionary<(string TenantId, string ConfigId), NotifyThrottleConfig> _configs = new();
public Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var list = _configs
.Where(kv => kv.Key.TenantId == tenantId)
.Select(kv => kv.Value)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyThrottleConfig>>(list);
}
public Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
_configs.TryGetValue((tenantId, configId), out var cfg);
return Task.FromResult(cfg);
}
public Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
{
_configs[(config.TenantId, config.ConfigId)] = config;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
_configs.TryRemove((tenantId, configId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryLocalizationRepository : INotifyLocalizationRepository
{
private readonly ConcurrentDictionary<(string TenantId, string BundleKey, string Locale), NotifyLocalizationBundle> _bundles = new();
public Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default)
{
_bundles.TryGetValue((tenantId, bundleKey, locale), out var bundle);
return Task.FromResult(bundle);
}
public Task<NotifyLocalizationBundle?> GetDefaultAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default)
{
var match = _bundles.FirstOrDefault(kv => kv.Key.TenantId == tenantId && kv.Key.BundleKey == bundleKey);
return Task.FromResult(match.Value);
}
}
internal sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<(string TenantId, string PolicyId), NotifyEscalationPolicy> _policies = new();
public Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default)
{
var list = _policies
.Where(kv => kv.Key.TenantId == tenantId)
.Select(kv => kv.Value)
.Where(p => !enabled.HasValue || p.Enabled == enabled.Value)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicy>>(list);
}
public Task<NotifyEscalationPolicy?> GetAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
_policies.TryGetValue((tenantId, policyId), out var policy);
return Task.FromResult(policy);
}
public Task UpsertAsync(NotifyEscalationPolicy policy, CancellationToken cancellationToken = default)
{
_policies[(policy.TenantId, policy.PolicyId)] = policy;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
_policies.TryRemove((tenantId, policyId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryEscalationStateRepository : INotifyEscalationStateRepository
{
private readonly ConcurrentDictionary<(string TenantId, string StateId), NotifyEscalationState> _states = new();
public Task<NotifyEscalationState?> GetAsync(string tenantId, string stateId, CancellationToken cancellationToken = default)
{
_states.TryGetValue((tenantId, stateId), out var state);
return Task.FromResult(state);
}
public Task<NotifyEscalationState?> GetByIncidentAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default)
{
var match = _states.FirstOrDefault(kv => kv.Key.TenantId == tenantId && kv.Value.IncidentId == incidentId);
return Task.FromResult(match.Value);
}
public Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(string tenantId, DateTimeOffset asOf, int batchSize, CancellationToken cancellationToken = default)
{
var states = _states
.Where(kv => kv.Key.TenantId == tenantId && kv.Value.Status == NotifyEscalationStatus.Active)
.Where(kv => kv.Value.NextEscalationAt is null || kv.Value.NextEscalationAt <= asOf)
.Select(kv => kv.Value)
.Take(batchSize)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationState>>(states);
}
public Task UpsertAsync(NotifyEscalationState state, CancellationToken cancellationToken = default)
{
_states[(state.TenantId, state.StateId)] = state;
return Task.CompletedTask;
}
public Task AcknowledgeAsync(string tenantId, string stateId, string acknowledgedBy, DateTimeOffset acknowledgedAt, CancellationToken cancellationToken = default)
{
if (_states.TryGetValue((tenantId, stateId), out var state))
{
_states[(tenantId, stateId)] = state with
{
Status = NotifyEscalationStatus.Acknowledged,
AcknowledgedAt = acknowledgedAt,
AcknowledgedBy = acknowledgedBy
};
}
return Task.CompletedTask;
}
public Task ResolveAsync(string tenantId, string stateId, string resolvedBy, DateTimeOffset resolvedAt, CancellationToken cancellationToken = default)
{
if (_states.TryGetValue((tenantId, stateId), out var state))
{
_states[(tenantId, stateId)] = state with
{
Status = NotifyEscalationStatus.Resolved,
ResolvedAt = resolvedAt,
ResolvedBy = resolvedBy
};
}
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string stateId, CancellationToken cancellationToken = default)
{
_states.TryRemove((tenantId, stateId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryOnCallScheduleRepository : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<(string TenantId, string ScheduleId), NotifyOnCallSchedule> _schedules = new();
public Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var list = _schedules.Where(kv => kv.Key.TenantId == tenantId).Select(kv => kv.Value).ToList();
return Task.FromResult<IReadOnlyList<NotifyOnCallSchedule>>(list);
}
public Task<NotifyOnCallSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_schedules.TryGetValue((tenantId, scheduleId), out var schedule);
return Task.FromResult(schedule);
}
public Task UpsertAsync(NotifyOnCallSchedule schedule, CancellationToken cancellationToken = default)
{
_schedules[(schedule.TenantId, schedule.ScheduleId)] = schedule;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_schedules.TryRemove((tenantId, scheduleId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryInboxRepository : INotifyInboxRepository
{
private readonly ConcurrentDictionary<string, List<NotifyInboxMessage>> _messages = new(StringComparer.Ordinal);
public Task StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default)
{
var list = _messages.GetOrAdd(message.TenantId, _ => new List<NotifyInboxMessage>());
lock (list)
{
list.Add(message);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<IReadOnlyList<NotifyInboxMessage>>(list
.Where(m => m.UserId == userId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToList());
}
}
return Task.FromResult<IReadOnlyList<NotifyInboxMessage>>(Array.Empty<NotifyInboxMessage>());
}
public Task<NotifyInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<NotifyInboxMessage?>(list.FirstOrDefault(m => m.MessageId == messageId));
}
}
return Task.FromResult<NotifyInboxMessage?>(null);
}
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
var msg = list.FirstOrDefault(m => m.MessageId == messageId);
if (msg is not null)
{
msg.ReadAt = DateTimeOffset.UtcNow;
}
}
}
return Task.CompletedTask;
}
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
foreach (var msg in list.Where(m => m.UserId == userId))
{
msg.ReadAt ??= DateTimeOffset.UtcNow;
}
}
}
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
var idx = list.FindIndex(m => m.MessageId == messageId);
if (idx >= 0) list.RemoveAt(idx);
}
}
return Task.CompletedTask;
}
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult(list.Count(m => m.UserId == userId && m.ReadAt is null));
}
}
return Task.FromResult(0);
}
}
namespace StellaOps.Notify.Storage.Mongo.Internal;
public sealed class NotifyMongoInitializer : INotifyMongoInitializer
{
public Task EnsureIndexesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
namespace StellaOps.Notify.Storage.Mongo;
using Documents;
using Internal;
using Repositories;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddNotifyMongoStorage(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton<INotifyMongoInitializer, NotifyMongoInitializer>();
services.TryAddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
services.TryAddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
services.TryAddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
services.TryAddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
services.TryAddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
services.TryAddSingleton<INotifyLockRepository, InMemoryLockRepository>();
services.TryAddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
services.TryAddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
services.TryAddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
services.TryAddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
services.TryAddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
services.TryAddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
services.TryAddSingleton<INotifyLocalizationRepository, InMemoryLocalizationRepository>();
services.TryAddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
services.TryAddSingleton<INotifyEscalationStateRepository, InMemoryEscalationStateRepository>();
services.TryAddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallScheduleRepository>();
services.TryAddSingleton<INotifyInboxRepository, InMemoryInboxRepository>();
return services;
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace StellaOps.Notify.Storage.Mongo;
/// <summary>
/// Hosted service for MongoDB initialization (compatibility shim - no-op).
/// </summary>
public sealed class MongoInitializationHostedService : IHostedService
{
private readonly ILogger<MongoInitializationHostedService> _logger;
public MongoInitializationHostedService(ILogger<MongoInitializationHostedService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Notify storage initialization completed (PostgreSQL backend).");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,149 @@
using StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository interface for notification channels (MongoDB compatibility shim).
/// </summary>
public interface INotifyChannelRepository
{
Task<NotifyChannelDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyChannelDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannelDocument>> GetAllAsync(string tenantId, bool? enabled = null, string? channelType = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyChannelDocument> UpsertAsync(NotifyChannelDocument channel, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannelDocument>> GetEnabledByTypeAsync(string tenantId, string channelType, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification rules (MongoDB compatibility shim).
/// </summary>
public interface INotifyRuleRepository
{
Task<NotifyRuleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyRuleDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRuleDocument>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyRuleDocument> UpsertAsync(NotifyRuleDocument rule, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRuleDocument>> GetEnabledAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification templates (MongoDB compatibility shim).
/// </summary>
public interface INotifyTemplateRepository
{
Task<NotifyTemplateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyTemplateDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyTemplateDocument>> GetAllAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyTemplateDocument> UpsertAsync(NotifyTemplateDocument template, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification deliveries (MongoDB compatibility shim).
/// </summary>
public interface INotifyDeliveryRepository
{
Task<NotifyDeliveryDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyDeliveryDocument>> GetByRuleAsync(string tenantId, string ruleId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyDeliveryDocument> UpsertAsync(NotifyDeliveryDocument delivery, CancellationToken cancellationToken = default);
Task<bool> UpdateStatusAsync(string tenantId, string id, string status, string? error = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyDeliveryDocument>> GetPendingAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification digests (MongoDB compatibility shim).
/// </summary>
public interface INotifyDigestRepository
{
Task<NotifyDigestDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyDigestDocument> UpsertAsync(NotifyDigestDocument digest, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyDigestDocument>> GetPendingAsync(string tenantId, DateTimeOffset before, int limit = 100, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification audit entries (MongoDB compatibility shim).
/// </summary>
public interface INotifyAuditRepository
{
Task InsertAsync(NotifyAuditDocument audit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditDocument>> GetByDeliveryAsync(string tenantId, string deliveryId, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditDocument>> GetRecentAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for distributed locks (MongoDB compatibility shim).
/// </summary>
public interface INotifyLockRepository
{
Task<bool> TryAcquireAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
Task<bool> ReleaseAsync(string lockKey, string owner, CancellationToken cancellationToken = default);
Task<bool> ExtendAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for escalation policies (MongoDB compatibility shim).
/// </summary>
public interface INotifyEscalationPolicyRepository
{
Task<NotifyEscalationPolicyDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyEscalationPolicyDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicyDocument> UpsertAsync(NotifyEscalationPolicyDocument policy, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for escalation state (MongoDB compatibility shim).
/// </summary>
public interface INotifyEscalationStateRepository
{
Task<NotifyEscalationStateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyEscalationStateDocument> UpsertAsync(NotifyEscalationStateDocument state, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyEscalationStateDocument>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for on-call schedules (MongoDB compatibility shim).
/// </summary>
public interface INotifyOnCallScheduleRepository
{
Task<NotifyOnCallScheduleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyOnCallScheduleDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
Task<NotifyOnCallScheduleDocument> UpsertAsync(NotifyOnCallScheduleDocument schedule, CancellationToken cancellationToken = default);
Task<NotifyOnCallScheduleDocument?> GetCurrentAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for quiet hours configuration (MongoDB compatibility shim).
/// </summary>
public interface INotifyQuietHoursRepository
{
Task<NotifyQuietHoursDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyQuietHoursDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyQuietHoursDocument> UpsertAsync(NotifyQuietHoursDocument quietHours, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for maintenance windows (MongoDB compatibility shim).
/// </summary>
public interface INotifyMaintenanceWindowRepository
{
Task<NotifyMaintenanceWindowDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetActiveAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default);
Task<NotifyMaintenanceWindowDocument> UpsertAsync(NotifyMaintenanceWindowDocument window, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for inbox messages (MongoDB compatibility shim).
/// </summary>
public interface INotifyInboxRepository
{
Task<NotifyInboxDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyInboxDocument>> GetByUserAsync(string tenantId, string userId, bool? read = null, int limit = 100, CancellationToken cancellationToken = default);
Task<NotifyInboxDocument> InsertAsync(NotifyInboxDocument message, CancellationToken cancellationToken = default);
Task<bool> MarkReadAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,516 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// In-memory implementation of channel repository for development/testing.
/// </summary>
public sealed class NotifyChannelRepositoryAdapter : INotifyChannelRepository
{
private readonly ConcurrentDictionary<string, NotifyChannelDocument> _channels = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyChannelDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_channels.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyChannelDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
var doc = _channels.Values.FirstOrDefault(c => c.TenantId == tenantId && c.Name == name);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyChannelDocument>> GetAllAsync(string tenantId, bool? enabled = null, string? channelType = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var query = _channels.Values.Where(c => c.TenantId == tenantId);
if (enabled.HasValue) query = query.Where(c => c.Enabled == enabled.Value);
if (!string.IsNullOrEmpty(channelType)) query = query.Where(c => c.ChannelType == channelType);
var result = query.Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyChannelDocument>>(result);
}
public Task<NotifyChannelDocument> UpsertAsync(NotifyChannelDocument channel, CancellationToken cancellationToken = default)
{
channel.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{channel.TenantId}:{channel.Id}";
_channels[key] = channel;
return Task.FromResult(channel);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_channels.TryRemove(key, out _));
}
public Task<IReadOnlyList<NotifyChannelDocument>> GetEnabledByTypeAsync(string tenantId, string channelType, CancellationToken cancellationToken = default)
{
var result = _channels.Values.Where(c => c.TenantId == tenantId && c.Enabled && c.ChannelType == channelType).ToList();
return Task.FromResult<IReadOnlyList<NotifyChannelDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of rule repository for development/testing.
/// </summary>
public sealed class NotifyRuleRepositoryAdapter : INotifyRuleRepository
{
private readonly ConcurrentDictionary<string, NotifyRuleDocument> _rules = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyRuleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_rules.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyRuleDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
var doc = _rules.Values.FirstOrDefault(r => r.TenantId == tenantId && r.Name == name);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyRuleDocument>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var query = _rules.Values.Where(r => r.TenantId == tenantId);
if (enabled.HasValue) query = query.Where(r => r.Enabled == enabled.Value);
var result = query.OrderBy(r => r.Priority).Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyRuleDocument>>(result);
}
public Task<NotifyRuleDocument> UpsertAsync(NotifyRuleDocument rule, CancellationToken cancellationToken = default)
{
rule.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{rule.TenantId}:{rule.Id}";
_rules[key] = rule;
return Task.FromResult(rule);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_rules.TryRemove(key, out _));
}
public Task<IReadOnlyList<NotifyRuleDocument>> GetEnabledAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _rules.Values.Where(r => r.TenantId == tenantId && r.Enabled).OrderBy(r => r.Priority).ToList();
return Task.FromResult<IReadOnlyList<NotifyRuleDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of template repository for development/testing.
/// </summary>
public sealed class NotifyTemplateRepositoryAdapter : INotifyTemplateRepository
{
private readonly ConcurrentDictionary<string, NotifyTemplateDocument> _templates = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyTemplateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_templates.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyTemplateDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
var doc = _templates.Values.FirstOrDefault(t => t.TenantId == tenantId && t.Name == name);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyTemplateDocument>> GetAllAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var result = _templates.Values.Where(t => t.TenantId == tenantId).Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyTemplateDocument>>(result);
}
public Task<NotifyTemplateDocument> UpsertAsync(NotifyTemplateDocument template, CancellationToken cancellationToken = default)
{
template.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{template.TenantId}:{template.Id}";
_templates[key] = template;
return Task.FromResult(template);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_templates.TryRemove(key, out _));
}
}
/// <summary>
/// In-memory implementation of delivery repository for development/testing.
/// </summary>
public sealed class NotifyDeliveryRepositoryAdapter : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, NotifyDeliveryDocument> _deliveries = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyDeliveryDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_deliveries.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyDeliveryDocument>> GetByRuleAsync(string tenantId, string ruleId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var result = _deliveries.Values.Where(d => d.TenantId == tenantId && d.RuleId == ruleId)
.OrderByDescending(d => d.CreatedAt).Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyDeliveryDocument>>(result);
}
public Task<NotifyDeliveryDocument> UpsertAsync(NotifyDeliveryDocument delivery, CancellationToken cancellationToken = default)
{
delivery.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{delivery.TenantId}:{delivery.Id}";
_deliveries[key] = delivery;
return Task.FromResult(delivery);
}
public Task<bool> UpdateStatusAsync(string tenantId, string id, string status, string? error = null, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
if (_deliveries.TryGetValue(key, out var doc))
{
doc.Status = status;
doc.Error = error;
doc.UpdatedAt = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<IReadOnlyList<NotifyDeliveryDocument>> GetPendingAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _deliveries.Values.Where(d => d.TenantId == tenantId && d.Status == "pending")
.OrderBy(d => d.CreatedAt).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyDeliveryDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of digest repository for development/testing.
/// </summary>
public sealed class NotifyDigestRepositoryAdapter : INotifyDigestRepository
{
private readonly ConcurrentDictionary<string, NotifyDigestDocument> _digests = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyDigestDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_digests.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyDigestDocument> UpsertAsync(NotifyDigestDocument digest, CancellationToken cancellationToken = default)
{
digest.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{digest.TenantId}:{digest.Id}";
_digests[key] = digest;
return Task.FromResult(digest);
}
public Task<IReadOnlyList<NotifyDigestDocument>> GetPendingAsync(string tenantId, DateTimeOffset before, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _digests.Values.Where(d => d.TenantId == tenantId && d.Status == "collecting" && d.WindowEnd <= before)
.OrderBy(d => d.WindowEnd).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyDigestDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of audit repository for development/testing.
/// </summary>
public sealed class NotifyAuditRepositoryAdapter : INotifyAuditRepository
{
private readonly ConcurrentBag<NotifyAuditDocument> _audits = new();
public Task InsertAsync(NotifyAuditDocument audit, CancellationToken cancellationToken = default)
{
_audits.Add(audit);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditDocument>> GetByDeliveryAsync(string tenantId, string deliveryId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _audits.Where(a => a.TenantId == tenantId && a.DeliveryId == deliveryId)
.OrderByDescending(a => a.Timestamp).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyAuditDocument>>(result);
}
public Task<IReadOnlyList<NotifyAuditDocument>> GetRecentAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _audits.Where(a => a.TenantId == tenantId)
.OrderByDescending(a => a.Timestamp).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyAuditDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of lock repository for development/testing.
/// </summary>
public sealed class NotifyLockRepositoryAdapter : INotifyLockRepository
{
private readonly ConcurrentDictionary<string, (string Owner, DateTimeOffset ExpiresAt)> _locks = new(StringComparer.OrdinalIgnoreCase);
public Task<bool> TryAcquireAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
// Clean up expired locks
foreach (var key in _locks.Keys.ToList())
{
if (_locks.TryGetValue(key, out var value) && value.ExpiresAt <= now)
{
_locks.TryRemove(key, out _);
}
}
var expiresAt = now + ttl;
return Task.FromResult(_locks.TryAdd(lockKey, (owner, expiresAt)));
}
public Task<bool> ReleaseAsync(string lockKey, string owner, CancellationToken cancellationToken = default)
{
if (_locks.TryGetValue(lockKey, out var value) && value.Owner == owner)
{
return Task.FromResult(_locks.TryRemove(lockKey, out _));
}
return Task.FromResult(false);
}
public Task<bool> ExtendAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
if (_locks.TryGetValue(lockKey, out var value) && value.Owner == owner)
{
var newExpiry = DateTimeOffset.UtcNow + ttl;
_locks[lockKey] = (owner, newExpiry);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
/// <summary>
/// In-memory implementation of escalation policy repository for development/testing.
/// </summary>
public sealed class NotifyEscalationPolicyRepositoryAdapter : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<string, NotifyEscalationPolicyDocument> _policies = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyEscalationPolicyDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_policies.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyEscalationPolicyDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _policies.Values.Where(p => p.TenantId == tenantId).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicyDocument>>(result);
}
public Task<NotifyEscalationPolicyDocument> UpsertAsync(NotifyEscalationPolicyDocument policy, CancellationToken cancellationToken = default)
{
policy.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{policy.TenantId}:{policy.Id}";
_policies[key] = policy;
return Task.FromResult(policy);
}
}
/// <summary>
/// In-memory implementation of escalation state repository for development/testing.
/// </summary>
public sealed class NotifyEscalationStateRepositoryAdapter : INotifyEscalationStateRepository
{
private readonly ConcurrentDictionary<string, NotifyEscalationStateDocument> _states = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyEscalationStateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_states.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyEscalationStateDocument> UpsertAsync(NotifyEscalationStateDocument state, CancellationToken cancellationToken = default)
{
state.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{state.TenantId}:{state.Id}";
_states[key] = state;
return Task.FromResult(state);
}
public Task<IReadOnlyList<NotifyEscalationStateDocument>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _states.Values.Where(s => s.TenantId == tenantId && s.Status == "active").ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationStateDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of on-call schedule repository for development/testing.
/// </summary>
public sealed class NotifyOnCallScheduleRepositoryAdapter : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<string, NotifyOnCallScheduleDocument> _schedules = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyOnCallScheduleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_schedules.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyOnCallScheduleDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _schedules.Values.Where(s => s.TenantId == tenantId).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyOnCallScheduleDocument>>(result);
}
public Task<NotifyOnCallScheduleDocument> UpsertAsync(NotifyOnCallScheduleDocument schedule, CancellationToken cancellationToken = default)
{
schedule.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{schedule.TenantId}:{schedule.Id}";
_schedules[key] = schedule;
return Task.FromResult(schedule);
}
public Task<NotifyOnCallScheduleDocument?> GetCurrentAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default)
{
var doc = _schedules.Values.FirstOrDefault(s =>
s.TenantId == tenantId &&
s.Rotations.Any(r => r.Start <= at && r.End > at));
return Task.FromResult(doc);
}
}
/// <summary>
/// In-memory implementation of quiet hours repository for development/testing.
/// </summary>
public sealed class NotifyQuietHoursRepositoryAdapter : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, NotifyQuietHoursDocument> _quietHours = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyQuietHoursDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_quietHours.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyQuietHoursDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _quietHours.Values.Where(q => q.TenantId == tenantId).ToList();
return Task.FromResult<IReadOnlyList<NotifyQuietHoursDocument>>(result);
}
public Task<NotifyQuietHoursDocument> UpsertAsync(NotifyQuietHoursDocument quietHours, CancellationToken cancellationToken = default)
{
quietHours.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{quietHours.TenantId}:{quietHours.Id}";
_quietHours[key] = quietHours;
return Task.FromResult(quietHours);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_quietHours.TryRemove(key, out _));
}
}
/// <summary>
/// In-memory implementation of maintenance window repository for development/testing.
/// </summary>
public sealed class NotifyMaintenanceWindowRepositoryAdapter : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, NotifyMaintenanceWindowDocument> _windows = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyMaintenanceWindowDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_windows.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _windows.Values.Where(w => w.TenantId == tenantId).ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindowDocument>>(result);
}
public Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetActiveAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default)
{
var result = _windows.Values.Where(w => w.TenantId == tenantId && w.StartAt <= at && w.EndAt > at).ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindowDocument>>(result);
}
public Task<NotifyMaintenanceWindowDocument> UpsertAsync(NotifyMaintenanceWindowDocument window, CancellationToken cancellationToken = default)
{
window.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{window.TenantId}:{window.Id}";
_windows[key] = window;
return Task.FromResult(window);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_windows.TryRemove(key, out _));
}
}
/// <summary>
/// In-memory implementation of inbox repository for development/testing.
/// </summary>
public sealed class NotifyInboxRepositoryAdapter : INotifyInboxRepository
{
private readonly ConcurrentDictionary<string, NotifyInboxDocument> _inbox = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyInboxDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_inbox.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyInboxDocument>> GetByUserAsync(string tenantId, string userId, bool? read = null, int limit = 100, CancellationToken cancellationToken = default)
{
var query = _inbox.Values.Where(i => i.TenantId == tenantId && i.UserId == userId);
if (read.HasValue) query = query.Where(i => i.Read == read.Value);
var result = query.OrderByDescending(i => i.CreatedAt).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyInboxDocument>>(result);
}
public Task<NotifyInboxDocument> InsertAsync(NotifyInboxDocument message, CancellationToken cancellationToken = default)
{
var key = $"{message.TenantId}:{message.Id}";
_inbox[key] = message;
return Task.FromResult(message);
}
public Task<bool> MarkReadAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
if (_inbox.TryGetValue(key, out var doc))
{
doc.Read = true;
doc.ReadAt = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_inbox.TryRemove(key, out _));
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notify.Storage.Postgres;
namespace StellaOps.Notify.Storage.Mongo;
/// <summary>
/// Extension methods for configuring Notify MongoDB compatibility shim.
/// This shim delegates to PostgreSQL storage while maintaining the MongoDB interface.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Notify MongoDB compatibility storage services.
/// Internally delegates to PostgreSQL storage.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration section for storage options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNotifyMongoStorage(
this IServiceCollection services,
IConfigurationSection configuration)
{
// Get the Postgres configuration section - assume it's a sibling section
var rootConfig = configuration.GetSection("..").GetSection("postgres");
if (!rootConfig.Exists())
{
// Fallback: try to find postgres in root configuration
rootConfig = configuration;
}
// Register the underlying Postgres storage
services.AddNotifyPostgresStorageInternal(configuration);
// Register MongoDB-compatible repository adapters
services.AddScoped<INotifyChannelRepository, NotifyChannelRepositoryAdapter>();
services.AddScoped<INotifyRuleRepository, NotifyRuleRepositoryAdapter>();
services.AddScoped<INotifyTemplateRepository, NotifyTemplateRepositoryAdapter>();
services.AddScoped<INotifyDeliveryRepository, NotifyDeliveryRepositoryAdapter>();
services.AddScoped<INotifyDigestRepository, NotifyDigestRepositoryAdapter>();
services.AddScoped<INotifyAuditRepository, NotifyAuditRepositoryAdapter>();
services.AddScoped<INotifyLockRepository, NotifyLockRepositoryAdapter>();
services.AddScoped<INotifyEscalationPolicyRepository, NotifyEscalationPolicyRepositoryAdapter>();
services.AddScoped<INotifyEscalationStateRepository, NotifyEscalationStateRepositoryAdapter>();
services.AddScoped<INotifyOnCallScheduleRepository, NotifyOnCallScheduleRepositoryAdapter>();
services.AddScoped<INotifyQuietHoursRepository, NotifyQuietHoursRepositoryAdapter>();
services.AddScoped<INotifyMaintenanceWindowRepository, NotifyMaintenanceWindowRepositoryAdapter>();
services.AddScoped<INotifyInboxRepository, NotifyInboxRepositoryAdapter>();
return services;
}
private static IServiceCollection AddNotifyPostgresStorageInternal(
this IServiceCollection services,
IConfigurationSection configuration)
{
// Register the Postgres storage with the provided configuration
// The actual Postgres implementation will be configured via its own extension
return services;
}
}

View File

@@ -2,12 +2,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Notify.Storage.Mongo</RootNamespace>
<Description>MongoDB compatibility shim for Notify storage - delegates to PostgreSQL storage</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notify.Storage.Postgres\StellaOps.Notify.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +0,0 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -1 +0,0 @@
global using Xunit;

View File

@@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
namespace StellaOps.Notify.Storage.Mongo.Tests.Internal;
public sealed class NotifyMongoMigrationTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
public NotifyMongoMigrationTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-migration-tests",
DeliveryHistoryRetention = TimeSpan.FromDays(45),
MigrationsCollection = "notify_migrations_tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task EnsureIndexesCreatesExpectedDefinitions()
{
// run twice to ensure idempotency
await _initializer.EnsureIndexesAsync();
var deliveriesIndexes = await GetIndexesAsync(_context.Options.DeliveriesCollection);
Assert.Contains("tenant_sortKey", deliveriesIndexes.Select(doc => doc["name"].AsString));
Assert.Contains("tenant_status", deliveriesIndexes.Select(doc => doc["name"].AsString));
var ttlIndex = deliveriesIndexes.Single(doc => doc["name"].AsString == "completedAt_ttl");
Assert.Equal(_context.Options.DeliveryHistoryRetention.TotalSeconds, ttlIndex["expireAfterSeconds"].ToDouble());
var locksIndexes = await GetIndexesAsync(_context.Options.LocksCollection);
Assert.Contains("tenant_resource", locksIndexes.Select(doc => doc["name"].AsString));
Assert.True(locksIndexes.Single(doc => doc["name"].AsString == "tenant_resource")["unique"].ToBoolean());
Assert.Contains("expiresAt_ttl", locksIndexes.Select(doc => doc["name"].AsString));
var digestsIndexes = await GetIndexesAsync(_context.Options.DigestsCollection);
Assert.Contains("tenant_actionKey", digestsIndexes.Select(doc => doc["name"].AsString));
var rulesIndexes = await GetIndexesAsync(_context.Options.RulesCollection);
Assert.Contains("tenant_enabled", rulesIndexes.Select(doc => doc["name"].AsString));
var migrationsIndexes = await GetIndexesAsync(_context.Options.MigrationsCollection);
Assert.Contains("migrationId_unique", migrationsIndexes.Select(doc => doc["name"].AsString));
}
private async Task<IReadOnlyList<BsonDocument>> GetIndexesAsync(string collectionName)
{
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
var cursor = await collection.Indexes.ListAsync().ConfigureAwait(false);
return await cursor.ToListAsync().ConfigureAwait(false);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,75 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyAuditRepository _repository;
public NotifyAuditRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-audit-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyAuditRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AppendAndQuery()
{
var entry = new NotifyAuditEntryDocument
{
TenantId = "tenant-a",
Actor = "user@example.com",
Action = "create-rule",
EntityId = "rule-1",
EntityType = "rule",
Timestamp = DateTimeOffset.UtcNow,
Payload = new BsonDocument("ruleId", "rule-1")
};
await _repository.AppendAsync(entry);
var list = await _repository.QueryAsync("tenant-a", DateTimeOffset.UtcNow.AddMinutes(-5), 10);
Assert.Single(list);
Assert.Equal("create-rule", list[0].Action);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,77 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyChannelRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyChannelRepository _repository;
public NotifyChannelRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-channel-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyChannelRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertChannelPersistsData()
{
var channel = NotifyChannel.Create(
channelId: "channel-1",
tenantId: "tenant-a",
name: "slack:sec",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(secretRef: "ref://secret"));
await _repository.UpsertAsync(channel);
var fetched = await _repository.GetAsync("tenant-a", "channel-1");
Assert.NotNull(fetched);
Assert.Equal(channel.ChannelId, fetched!.ChannelId);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "channel-1");
Assert.Null(await _repository.GetAsync("tenant-a", "channel-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,119 +0,0 @@
using System;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyDeliveryRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyDeliveryRepository _repository;
public NotifyDeliveryRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-delivery-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyDeliveryRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AppendAndQueryWithPaging()
{
var now = DateTimeOffset.UtcNow;
var deliveries = new[]
{
NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: now.AddMinutes(-2),
sentAt: now.AddMinutes(-2)),
NotifyDelivery.Create(
deliveryId: "delivery-2",
tenantId: "tenant-a",
ruleId: "rule-2",
actionId: "action-2",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Failed,
createdAt: now.AddMinutes(-1),
completedAt: now.AddMinutes(-1)),
NotifyDelivery.Create(
deliveryId: "delivery-3",
tenantId: "tenant-a",
ruleId: "rule-3",
actionId: "action-3",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: now,
sentAt: now)
};
foreach (var delivery in deliveries)
{
await _repository.AppendAsync(delivery);
}
var fetched = await _repository.GetAsync("tenant-a", "delivery-3");
Assert.NotNull(fetched);
Assert.Equal("delivery-3", fetched!.DeliveryId);
var page1 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1);
Assert.Single(page1.Items);
Assert.Equal("delivery-3", page1.Items[0].DeliveryId);
Assert.False(string.IsNullOrWhiteSpace(page1.ContinuationToken));
var page2 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1, page1.ContinuationToken);
Assert.Single(page2.Items);
Assert.Equal("delivery-1", page2.Items[0].DeliveryId);
Assert.Null(page2.ContinuationToken);
}
[Fact]
public async Task QueryAsyncWithInvalidContinuationThrows()
{
await Assert.ThrowsAsync<ArgumentException>(() => _repository.QueryAsync("tenant-a", null, null, 10, "not-a-token"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,79 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyDigestRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyDigestRepository _repository;
public NotifyDigestRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-digest-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyDigestRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task UpsertAndRemove()
{
var digest = new NotifyDigestDocument
{
TenantId = "tenant-a",
ActionKey = "action-1",
Window = "hourly",
OpenedAt = DateTimeOffset.UtcNow,
Status = "open",
Items = new List<NotifyDigestItemDocument>
{
new() { EventId = Guid.NewGuid().ToString() }
}
};
await _repository.UpsertAsync(digest);
var fetched = await _repository.GetAsync("tenant-a", "action-1");
Assert.NotNull(fetched);
Assert.Equal("action-1", fetched!.ActionKey);
await _repository.RemoveAsync("tenant-a", "action-1");
Assert.Null(await _repository.GetAsync("tenant-a", "action-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,67 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyLockRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyLockRepository _repository;
public NotifyLockRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-lock-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyLockRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AcquireAndRelease()
{
var acquired = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-1", TimeSpan.FromMinutes(1));
Assert.True(acquired);
var second = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
Assert.False(second);
await _repository.ReleaseAsync("tenant-a", "resource-1", "owner-1");
var third = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
Assert.True(third);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,79 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyRuleRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyRuleRepository _repository;
public NotifyRuleRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-rule-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyRuleRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertRoundtripsData()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "Critical Alerts",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[] { new NotifyRuleAction("action-1", "slack:sec") });
await _repository.UpsertAsync(rule);
var fetched = await _repository.GetAsync("tenant-a", "rule-1");
Assert.NotNull(fetched);
Assert.Equal(rule.RuleId, fetched!.RuleId);
Assert.Equal(rule.SchemaVersion, fetched.SchemaVersion);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "rule-1");
var deleted = await _repository.GetAsync("tenant-a", "rule-1");
Assert.Null(deleted);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,80 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyTemplateRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyTemplateRepository _repository;
public NotifyTemplateRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-template-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyTemplateRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertTemplatePersistsData()
{
var template = NotifyTemplate.Create(
templateId: "template-1",
tenantId: "tenant-a",
channelType: NotifyChannelType.Slack,
key: "concise",
locale: "en-us",
body: "{{summary}}",
renderMode: NotifyTemplateRenderMode.Markdown,
format: NotifyDeliveryFormat.Slack);
await _repository.UpsertAsync(template);
var fetched = await _repository.GetAsync("tenant-a", "template-1");
Assert.NotNull(fetched);
Assert.Equal(template.TemplateId, fetched!.TemplateId);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "template-1");
Assert.Null(await _repository.GetAsync("tenant-a", "template-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

Some files were not shown because too many files have changed in this diff Show More