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:
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.1" />
|
||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.1" />
|
||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NuGet.Versioning" Version="6.9.1" />
|
||||
<PackageReference Include="NuGet.Versioning" Version="6.13.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 _));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -1 +0,0 @@
|
||||
global using Xunit;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user