feat: Add DigestUpsertRequest and LockEntity models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

- Introduced DigestUpsertRequest for handling digest upsert requests with properties like ChannelId, Recipient, DigestKey, Events, and CollectUntil.
- Created LockEntity to represent a lightweight distributed lock entry with properties such as Id, TenantId, Resource, Owner, ExpiresAt, and CreatedAt.

feat: Implement ILockRepository interface and LockRepository class

- Defined ILockRepository interface with methods for acquiring and releasing locks.
- Implemented LockRepository class with methods to try acquiring a lock and releasing it, using SQL for upsert operations.

feat: Add SurfaceManifestPointer record for manifest pointers

- Introduced SurfaceManifestPointer to represent a minimal pointer to a Surface.FS manifest associated with an image digest.

feat: Create PolicySimulationInputLock and related validation logic

- Added PolicySimulationInputLock record to describe policy simulation inputs and expected digests.
- Implemented validation logic for policy simulation inputs, including checks for digest drift and shadow mode requirements.

test: Add unit tests for ReplayVerificationService and ReplayVerifier

- Created ReplayVerificationServiceTests to validate the behavior of the ReplayVerificationService under various scenarios.
- Developed ReplayVerifierTests to ensure the correctness of the ReplayVerifier logic.

test: Implement PolicySimulationInputLockValidatorTests

- Added tests for PolicySimulationInputLockValidator to verify the validation logic against expected inputs and conditions.

chore: Add cosign key example and signing scripts

- Included a placeholder cosign key example for development purposes.
- Added a script for signing Signals artifacts using cosign with support for both v2 and v3.

chore: Create script for uploading evidence to the evidence locker

- Developed a script to upload evidence to the evidence locker, ensuring required environment variables are set.
This commit is contained in:
StellaOps Bot
2025-12-03 07:51:50 +02:00
parent 37cba83708
commit e923880694
171 changed files with 6567 additions and 2952 deletions

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Contracts;
internal sealed record DigestUpsertRequest(
string ChannelId,
string Recipient,
string DigestKey,
JsonArray? Events,
DateTimeOffset? CollectUntil);

View File

@@ -20,9 +20,9 @@ public sealed class NotifyWebServiceOptions
public AuthorityOptions Authority { get; set; } = new();
/// <summary>
/// Mongo storage configuration for configuration state and audit logs.
/// </summary>
public StorageOptions Storage { get; set; } = new();
/// Storage configuration (PostgreSQL-only after cutover).
/// </summary>
public StorageOptions Storage { get; set; } = new();
/// <summary>
/// Plug-in loader configuration.
@@ -71,7 +71,7 @@ public sealed class NotifyWebServiceOptions
public sealed class StorageOptions
{
public string Driver { get; set; } = "mongo";
public string Driver { get; set; } = "postgres";
public string ConnectionString { get; set; } = string.Empty;

View File

@@ -19,29 +19,10 @@ internal static class NotifyWebServiceOptionsValidator
ArgumentNullException.ThrowIfNull(storage);
var driver = storage.Driver ?? string.Empty;
if (!string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(driver, "memory", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'.");
}
if (string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
{
throw new InvalidOperationException("notify:storage:connectionString must be provided.");
}
if (string.IsNullOrWhiteSpace(storage.Database))
{
throw new InvalidOperationException("notify:storage:database must be provided.");
}
if (storage.CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("notify:storage:commandTimeoutSeconds must be positive.");
}
}
if (!string.Equals(driver, "postgres", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Only 'postgres' is supported after cutover.");
}
}
private static void ValidateAuthority(NotifyWebServiceOptions.AuthorityOptions authority)

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,11 @@
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,360 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.WebService.Storage.InMemory;
internal static class InMemoryStorageModule
{
public static IServiceCollection AddInMemoryNotifyStorage(this IServiceCollection services)
{
services.AddSingleton<InMemoryStore>();
services.AddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
services.AddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
services.AddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
services.AddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
services.AddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
services.AddSingleton<INotifyLockRepository, InMemoryLockRepository>();
services.AddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
return services;
}
private sealed class InMemoryStore
{
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> Rules { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> Channels { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyTemplate>> Templates { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDelivery>> Deliveries { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDigestDocument>> Digests { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, LockEntry>> Locks { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentQueue<NotifyAuditEntryDocument>> AuditEntries { get; } = new(StringComparer.Ordinal);
}
private sealed class InMemoryRuleRepository : INotifyRuleRepository
{
private readonly InMemoryStore _store;
public InMemoryRuleRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
var map = _store.Rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
map[rule.RuleId] = rule;
return Task.CompletedTask;
}
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_store.Rules.TryGetValue(tenantId, out var map) && map.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 (_store.Rules.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyRule>>(map.Values.OrderBy(static r => r.RuleId, StringComparer.Ordinal).ToList());
}
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
}
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_store.Rules.TryGetValue(tenantId, out var map))
{
map.TryRemove(ruleId, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryChannelRepository : INotifyChannelRepository
{
private readonly InMemoryStore _store;
public InMemoryChannelRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
var map = _store.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 (_store.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 (_store.Channels.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.OrderBy(static c => c.ChannelId, StringComparer.Ordinal).ToList());
}
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
}
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_store.Channels.TryGetValue(tenantId, out var map))
{
map.TryRemove(channelId, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
{
private readonly InMemoryStore _store;
public InMemoryTemplateRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
var map = _store.Templates.GetOrAdd(template.TenantId, _ => new ConcurrentDictionary<string, NotifyTemplate>(StringComparer.Ordinal));
map[template.TemplateId] = template;
return Task.CompletedTask;
}
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
if (_store.Templates.TryGetValue(tenantId, out var map) && map.TryGetValue(templateId, out var template))
{
return Task.FromResult<NotifyTemplate?>(template);
}
return Task.FromResult<NotifyTemplate?>(null);
}
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_store.Templates.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(map.Values.OrderBy(static t => t.TemplateId, StringComparer.Ordinal).ToList());
}
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(Array.Empty<NotifyTemplate>());
}
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
if (_store.Templates.TryGetValue(tenantId, out var map))
{
map.TryRemove(templateId, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly InMemoryStore _store;
public InMemoryDeliveryRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
=> UpdateAsync(delivery, cancellationToken);
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
var map = _store.Deliveries.GetOrAdd(delivery.TenantId, _ => new ConcurrentDictionary<string, NotifyDelivery>(StringComparer.Ordinal));
map[delivery.DeliveryId] = delivery;
return Task.CompletedTask;
}
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
{
if (_store.Deliveries.TryGetValue(tenantId, out var map) && map.TryGetValue(deliveryId, out var delivery))
{
return Task.FromResult<NotifyDelivery?>(delivery);
}
return Task.FromResult<NotifyDelivery?>(null);
}
public Task<NotifyDeliveryQueryResult> QueryAsync(string tenantId, DateTimeOffset? since, string? status, int? limit, string? continuationToken = null, CancellationToken cancellationToken = default)
{
if (!_store.Deliveries.TryGetValue(tenantId, out var map))
{
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
}
var query = map.Values.AsEnumerable();
if (since.HasValue)
{
query = query.Where(d => d.CreatedAt >= since.Value);
}
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotifyDeliveryStatus>(status, true, out var parsed))
{
query = query.Where(d => d.Status == parsed);
}
query = query.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.DeliveryId, StringComparer.Ordinal);
if (limit.HasValue && limit.Value > 0)
{
query = query.Take(limit.Value);
}
var items = query.ToList();
return Task.FromResult(new NotifyDeliveryQueryResult(items, ContinuationToken: null));
}
}
private sealed class InMemoryDigestRepository : INotifyDigestRepository
{
private readonly InMemoryStore _store;
public InMemoryDigestRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
{
var map = _store.Digests.GetOrAdd(document.TenantId, _ => new ConcurrentDictionary<string, NotifyDigestDocument>(StringComparer.Ordinal));
map[document.ActionKey] = document;
return Task.CompletedTask;
}
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
if (_store.Digests.TryGetValue(tenantId, out var map) && map.TryGetValue(actionKey, out var document))
{
return Task.FromResult<NotifyDigestDocument?>(document);
}
return Task.FromResult<NotifyDigestDocument?>(null);
}
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
if (_store.Digests.TryGetValue(tenantId, out var map))
{
map.TryRemove(actionKey, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly InMemoryStore _store;
public InMemoryLockRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var map = _store.Locks.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, LockEntry>(StringComparer.Ordinal));
var now = DateTimeOffset.UtcNow;
var entry = map.GetOrAdd(resource, _ => new LockEntry(owner, now, now.Add(ttl)));
lock (entry)
{
if (entry.Owner == owner || entry.ExpiresAt <= now)
{
entry.Owner = owner;
entry.AcquiredAt = now;
entry.ExpiresAt = now.Add(ttl);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
if (_store.Locks.TryGetValue(tenantId, out var map) && map.TryGetValue(resource, out var entry))
{
lock (entry)
{
if (entry.Owner == owner)
{
map.TryRemove(resource, out _);
}
}
}
return Task.CompletedTask;
}
}
private sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly InMemoryStore _store;
public InMemoryAuditRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
var queue = _store.AuditEntries.GetOrAdd(entry.TenantId, _ => new ConcurrentQueue<NotifyAuditEntryDocument>());
queue.Enqueue(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
if (!_store.AuditEntries.TryGetValue(tenantId, out var queue))
{
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
}
var items = queue
.Where(entry => !since.HasValue || entry.Timestamp >= since.Value)
.OrderByDescending(entry => entry.Timestamp)
.ThenBy(entry => entry.Id.ToString(), StringComparer.Ordinal)
.ToList();
if (limit.HasValue && limit.Value > 0 && items.Count > limit.Value)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}
private sealed class LockEntry
{
public LockEntry(string owner, DateTimeOffset acquiredAt, DateTimeOffset expiresAt)
{
Owner = owner;
AcquiredAt = acquiredAt;
ExpiresAt = expiresAt;
}
public string Owner { get; set; }
public DateTimeOffset AcquiredAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}
}