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
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:
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user