Centralize CultMesh authority resolution
This commit is contained in:
parent
eeb6166e78
commit
fb24e30ab6
6 changed files with 547 additions and 73 deletions
|
|
@ -2978,6 +2978,7 @@ namespace GameCult.Mesh
|
|||
/// <summary>
|
||||
/// Creates a CultNet RUDP client transport from the first peer authorized for a Verse role.
|
||||
/// </summary>
|
||||
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
|
||||
public static CultNetRudpSocketTransportConnection CreateRudpClientForAuthorizedPeer(
|
||||
string runtimeId,
|
||||
uint connectionId,
|
||||
|
|
@ -2991,7 +2992,27 @@ namespace GameCult.Mesh
|
|||
{
|
||||
if (peers == null) throw new ArgumentNullException(nameof(peers));
|
||||
if (leases == null) throw new ArgumentNullException(nameof(leases));
|
||||
var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at);
|
||||
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases,
|
||||
at.HasValue ? new CultMeshFixedAuthorityClock(at.Value) : null);
|
||||
return CreateRudpClientForAuthorizedPeer(runtimeId, connectionId, peers, resolver, 0, verseId, role, shardId, null, options);
|
||||
}
|
||||
|
||||
/// <summary>Creates a RUDP client from the first contact accepted by the authority resolver.</summary>
|
||||
public static CultNetRudpSocketTransportConnection CreateRudpClientForAuthorizedPeer(
|
||||
string runtimeId,
|
||||
uint connectionId,
|
||||
CultMeshPeerCatalog peers,
|
||||
CultMeshAuthorityResolver authority,
|
||||
long authorityEpoch,
|
||||
string verseId,
|
||||
string role,
|
||||
string? shardId = null,
|
||||
string? resourceScope = null,
|
||||
CultMeshRudpSocketOptions? options = null)
|
||||
{
|
||||
if (peers == null) throw new ArgumentNullException(nameof(peers));
|
||||
if (authority == null) throw new ArgumentNullException(nameof(authority));
|
||||
var peer = peers.FirstAuthorized(verseId, role, authority, authorityEpoch, shardId, resourceScope);
|
||||
if (peer == null)
|
||||
{
|
||||
throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}.");
|
||||
|
|
@ -3054,6 +3075,7 @@ namespace GameCult.Mesh
|
|||
/// <summary>
|
||||
/// Creates and handshakes a CultNet RUDP client transport from the first peer authorized for a Verse role.
|
||||
/// </summary>
|
||||
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
|
||||
public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer(
|
||||
string runtimeId,
|
||||
uint connectionId,
|
||||
|
|
@ -3067,7 +3089,27 @@ namespace GameCult.Mesh
|
|||
{
|
||||
if (peers == null) throw new ArgumentNullException(nameof(peers));
|
||||
if (leases == null) throw new ArgumentNullException(nameof(leases));
|
||||
var peer = peers.FirstAuthorized(verseId, role, leases, shardId, at);
|
||||
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases,
|
||||
at.HasValue ? new CultMeshFixedAuthorityClock(at.Value) : null);
|
||||
return ConnectRudpClientForAuthorizedPeer(runtimeId, connectionId, peers, resolver, 0, verseId, role, shardId, null, options);
|
||||
}
|
||||
|
||||
/// <summary>Creates and handshakes a RUDP client from the first contact accepted by the authority resolver.</summary>
|
||||
public static CultNetRudpSocketTransportConnection ConnectRudpClientForAuthorizedPeer(
|
||||
string runtimeId,
|
||||
uint connectionId,
|
||||
CultMeshPeerCatalog peers,
|
||||
CultMeshAuthorityResolver authority,
|
||||
long authorityEpoch,
|
||||
string verseId,
|
||||
string role,
|
||||
string? shardId = null,
|
||||
string? resourceScope = null,
|
||||
CultMeshRudpClientOptions? options = null)
|
||||
{
|
||||
if (peers == null) throw new ArgumentNullException(nameof(peers));
|
||||
if (authority == null) throw new ArgumentNullException(nameof(authority));
|
||||
var peer = peers.FirstAuthorized(verseId, role, authority, authorityEpoch, shardId, resourceScope);
|
||||
if (peer == null)
|
||||
{
|
||||
throw new InvalidOperationException($"No authorized RUDP peer for role {role} in Verse {verseId}.");
|
||||
|
|
@ -3076,6 +3118,14 @@ namespace GameCult.Mesh
|
|||
return ConnectRudpClientForPeer(runtimeId, connectionId, peer, options);
|
||||
}
|
||||
|
||||
private sealed class CultMeshFixedAuthorityClock : ICultMeshClock
|
||||
{
|
||||
public CultMeshFixedAuthorityClock(DateTimeOffset utcNow) => UtcNow = utcNow;
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, System.Threading.CancellationToken cancellationToken = default) =>
|
||||
System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a committer for writing quorum simulation facts to a mesh database.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace GameCult.Mesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Grants one peer authority to perform roles for a Verse over a bounded interval.
|
||||
/// </summary>
|
||||
public sealed class CultMeshAuthorityLease
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an authority lease.
|
||||
/// </summary>
|
||||
public const string CurrentSchemaVersion = "gamecult.mesh.authority_lease.v1";
|
||||
public const string LegacySchemaVersion = "gamecult.mesh.authority_lease.v0";
|
||||
|
||||
public CultMeshAuthorityLease(
|
||||
string leaseId,
|
||||
string verseId,
|
||||
|
|
@ -21,7 +19,10 @@ namespace GameCult.Mesh
|
|||
string issuerRuntimeId,
|
||||
DateTimeOffset validFrom,
|
||||
DateTimeOffset expiresAt,
|
||||
string? signature = null)
|
||||
string? signature = null,
|
||||
long? authorityEpoch = null,
|
||||
IEnumerable<string>? resourceScopes = null,
|
||||
string? schemaVersion = null)
|
||||
{
|
||||
LeaseId = RequireNonEmpty(leaseId, nameof(leaseId));
|
||||
VerseId = RequireNonEmpty(verseId, nameof(verseId));
|
||||
|
|
@ -32,109 +33,239 @@ namespace GameCult.Mesh
|
|||
ValidFrom = validFrom;
|
||||
ExpiresAt = expiresAt;
|
||||
Signature = signature;
|
||||
if (expiresAt <= validFrom)
|
||||
{
|
||||
throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt));
|
||||
}
|
||||
AuthorityEpoch = authorityEpoch;
|
||||
ResourceScopes = Clean(resourceScopes);
|
||||
SchemaVersion = schemaVersion ?? (authorityEpoch.HasValue ? CurrentSchemaVersion : LegacySchemaVersion);
|
||||
if (expiresAt <= validFrom) throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt));
|
||||
if (authorityEpoch < 0) throw new ArgumentOutOfRangeException(nameof(authorityEpoch));
|
||||
}
|
||||
|
||||
/// <summary>Gets the stable lease id.</summary>
|
||||
public string LeaseId { get; }
|
||||
/// <summary>Gets the Verse id governed by the lease.</summary>
|
||||
public string VerseId { get; }
|
||||
/// <summary>Gets the peer id receiving authority.</summary>
|
||||
public string PeerId { get; }
|
||||
/// <summary>Gets the roles granted by this lease.</summary>
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
/// <summary>Gets shard ids covered by this lease. Empty means every shard in the Verse.</summary>
|
||||
public IReadOnlyList<string> ShardIds { get; }
|
||||
/// <summary>Gets the runtime that issued the lease.</summary>
|
||||
public string IssuerRuntimeId { get; }
|
||||
/// <summary>Gets the first timestamp when the lease is valid.</summary>
|
||||
public DateTimeOffset ValidFrom { get; }
|
||||
/// <summary>Gets the timestamp when the lease expires.</summary>
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
/// <summary>Gets an optional signature over the lease.</summary>
|
||||
public string? Signature { get; }
|
||||
public long? AuthorityEpoch { get; }
|
||||
public IReadOnlyList<string> ResourceScopes { get; }
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this lease is valid at the supplied time.
|
||||
/// </summary>
|
||||
public bool IsValidAt(DateTimeOffset at)
|
||||
{
|
||||
return at >= ValidFrom && at < ExpiresAt;
|
||||
}
|
||||
public bool IsValidAt(DateTimeOffset at) => at >= ValidFrom && at < ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this lease covers a peer card, role, and shard.
|
||||
/// </summary>
|
||||
[Obsolete("Authority decisions belong to CultMeshAuthorityResolver. This method denies leases that lack v1 signed authority evidence.")]
|
||||
public bool Covers(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null)
|
||||
{
|
||||
if (peer == null) throw new ArgumentNullException(nameof(peer));
|
||||
if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role));
|
||||
var time = at ?? DateTimeOffset.UtcNow;
|
||||
return IsValidAt(time) &&
|
||||
string.Equals(VerseId, peer.VerseId, StringComparison.Ordinal) &&
|
||||
string.Equals(PeerId, peer.PeerId, StringComparison.Ordinal) &&
|
||||
string.Equals(LeaseId, peer.AuthorityLeaseId, StringComparison.Ordinal) &&
|
||||
Roles.Contains(role, StringComparer.Ordinal) &&
|
||||
peer.Roles.Contains(role, StringComparer.Ordinal) &&
|
||||
(string.IsNullOrWhiteSpace(shardId) ||
|
||||
ShardIds.Count == 0 ||
|
||||
ShardIds.Contains(shardId!, StringComparer.Ordinal));
|
||||
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(
|
||||
new SingleLeaseSource(this),
|
||||
at.HasValue ? new FixedClock(at.Value) : CultMeshSystemClock.Instance);
|
||||
return resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, AuthorityEpoch ?? -1)).IsAuthorized;
|
||||
}
|
||||
|
||||
private static string[] Clean(IEnumerable<string>? values)
|
||||
private static string[] Clean(IEnumerable<string>? values) =>
|
||||
values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() ?? Array.Empty<string>();
|
||||
|
||||
private static string RequireNonEmpty(string value, string paramName) =>
|
||||
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", paramName) : value;
|
||||
|
||||
private sealed class SingleLeaseSource : ICultMeshAuthorityLeaseSource
|
||||
{
|
||||
return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
||||
?? Array.Empty<string>();
|
||||
private readonly CultMeshAuthorityLease _lease;
|
||||
public SingleLeaseSource(CultMeshAuthorityLease lease) => _lease = lease;
|
||||
public CultMeshAuthorityLease? Get(string leaseId) => string.Equals(leaseId, _lease.LeaseId, StringComparison.Ordinal) ? _lease : null;
|
||||
}
|
||||
|
||||
private static string RequireNonEmpty(string value, string paramName)
|
||||
private sealed class FixedClock : ICultMeshClock
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value must be non-empty.", paramName)
|
||||
: value;
|
||||
public FixedClock(DateTimeOffset now) => UtcNow = now;
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) =>
|
||||
System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local authority lease catalog.
|
||||
/// </summary>
|
||||
public sealed class CultMeshAuthorityLeaseCatalog
|
||||
public interface ICultMeshAuthorityLeaseSource
|
||||
{
|
||||
private readonly Dictionary<string, CultMeshAuthorityLease> _leases = new(StringComparer.Ordinal);
|
||||
CultMeshAuthorityLease? Get(string leaseId);
|
||||
}
|
||||
|
||||
/// <summary>Gets all known leases.</summary>
|
||||
public IReadOnlyList<CultMeshAuthorityLease> Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
|
||||
public interface ICultMeshAuthoritySignatureVerifier
|
||||
{
|
||||
bool Verify(CultMeshAuthorityLease lease);
|
||||
}
|
||||
|
||||
/// <summary>Adds or replaces an authority lease.</summary>
|
||||
public void Upsert(CultMeshAuthorityLease lease)
|
||||
public interface ICultMeshAuthorityRevocationSource
|
||||
{
|
||||
bool IsRevoked(string leaseId, long authorityEpoch);
|
||||
}
|
||||
|
||||
public enum CultMeshAuthorityDenialReason
|
||||
{
|
||||
None,
|
||||
MissingLeaseReference,
|
||||
LeaseNotFound,
|
||||
UnsupportedLeaseVersion,
|
||||
MissingSignature,
|
||||
InvalidSignature,
|
||||
NotYetValid,
|
||||
Expired,
|
||||
Revoked,
|
||||
VerseMismatch,
|
||||
PeerMismatch,
|
||||
LeaseReferenceMismatch,
|
||||
RoleNotGranted,
|
||||
RoleNotAdvertised,
|
||||
ShardNotGranted,
|
||||
ShardNotAdvertised,
|
||||
EpochMismatch,
|
||||
ResourceScopeNotGranted
|
||||
}
|
||||
|
||||
public sealed class CultMeshAuthorityRequest
|
||||
{
|
||||
public CultMeshAuthorityRequest(CultMeshPeerCard peer, string role, string? shardId, long authorityEpoch, string? resourceScope = null)
|
||||
{
|
||||
if (lease == null) throw new ArgumentNullException(nameof(lease));
|
||||
_leases[lease.LeaseId] = lease;
|
||||
Peer = peer ?? throw new ArgumentNullException(nameof(peer));
|
||||
Role = string.IsNullOrWhiteSpace(role) ? throw new ArgumentException("Value must be non-empty.", nameof(role)) : role;
|
||||
if (authorityEpoch < 0) throw new ArgumentOutOfRangeException(nameof(authorityEpoch));
|
||||
ShardId = shardId;
|
||||
AuthorityEpoch = authorityEpoch;
|
||||
ResourceScope = resourceScope;
|
||||
}
|
||||
|
||||
/// <summary>Gets a lease by id, if known.</summary>
|
||||
public CultMeshPeerCard Peer { get; }
|
||||
public string Role { get; }
|
||||
public string? ShardId { get; }
|
||||
public long AuthorityEpoch { get; }
|
||||
public string? ResourceScope { get; }
|
||||
}
|
||||
|
||||
public sealed class CultMeshAuthorityDecision
|
||||
{
|
||||
internal CultMeshAuthorityDecision(CultMeshAuthorityRequest request, CultMeshAuthorityLease? lease, CultMeshAuthorityDenialReason reason)
|
||||
{
|
||||
Request = request;
|
||||
Lease = lease;
|
||||
DenialReason = reason;
|
||||
}
|
||||
|
||||
public CultMeshAuthorityRequest Request { get; }
|
||||
public CultMeshAuthorityLease? Lease { get; }
|
||||
public bool IsAuthorized => DenialReason == CultMeshAuthorityDenialReason.None;
|
||||
public CultMeshAuthorityDenialReason DenialReason { get; }
|
||||
public string? LeaseId => Lease?.LeaseId ?? Request.Peer.AuthorityLeaseId;
|
||||
public string? IssuerRuntimeId => Lease?.IssuerRuntimeId;
|
||||
}
|
||||
|
||||
public sealed class CultMeshAuthorityResolver
|
||||
{
|
||||
private readonly ICultMeshAuthorityLeaseSource _leases;
|
||||
private readonly ICultMeshAuthoritySignatureVerifier _signatures;
|
||||
private readonly ICultMeshAuthorityRevocationSource _revocations;
|
||||
private readonly ICultMeshClock _clock;
|
||||
private readonly ICultMeshDiagnosticSink _diagnostics;
|
||||
private long _diagnosticSequence;
|
||||
|
||||
public CultMeshAuthorityResolver(
|
||||
ICultMeshAuthorityLeaseSource leases,
|
||||
ICultMeshAuthoritySignatureVerifier signatures,
|
||||
ICultMeshAuthorityRevocationSource revocations,
|
||||
ICultMeshClock clock,
|
||||
ICultMeshDiagnosticSink? diagnostics = null)
|
||||
{
|
||||
_leases = leases ?? throw new ArgumentNullException(nameof(leases));
|
||||
_signatures = signatures ?? throw new ArgumentNullException(nameof(signatures));
|
||||
_revocations = revocations ?? throw new ArgumentNullException(nameof(revocations));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_diagnostics = diagnostics ?? CultMeshNullDiagnosticSink.Instance;
|
||||
}
|
||||
|
||||
public CultMeshAuthorityDecision Resolve(CultMeshAuthorityRequest request)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
var leaseId = request.Peer.AuthorityLeaseId;
|
||||
if (string.IsNullOrWhiteSpace(leaseId)) return Decide(request, null, CultMeshAuthorityDenialReason.MissingLeaseReference);
|
||||
var lease = _leases.Get(leaseId!);
|
||||
if (lease == null) return Decide(request, null, CultMeshAuthorityDenialReason.LeaseNotFound);
|
||||
if (!string.Equals(lease.SchemaVersion, CultMeshAuthorityLease.CurrentSchemaVersion, StringComparison.Ordinal) || !lease.AuthorityEpoch.HasValue)
|
||||
return Decide(request, lease, CultMeshAuthorityDenialReason.UnsupportedLeaseVersion);
|
||||
if (string.IsNullOrWhiteSpace(lease.Signature)) return Decide(request, lease, CultMeshAuthorityDenialReason.MissingSignature);
|
||||
if (!_signatures.Verify(lease)) return Decide(request, lease, CultMeshAuthorityDenialReason.InvalidSignature);
|
||||
if (_clock.UtcNow < lease.ValidFrom) return Decide(request, lease, CultMeshAuthorityDenialReason.NotYetValid);
|
||||
if (_clock.UtcNow >= lease.ExpiresAt) return Decide(request, lease, CultMeshAuthorityDenialReason.Expired);
|
||||
if (_revocations.IsRevoked(lease.LeaseId, lease.AuthorityEpoch.Value)) return Decide(request, lease, CultMeshAuthorityDenialReason.Revoked);
|
||||
if (!string.Equals(lease.VerseId, request.Peer.VerseId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.VerseMismatch);
|
||||
if (!string.Equals(lease.PeerId, request.Peer.PeerId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.PeerMismatch);
|
||||
if (!string.Equals(lease.LeaseId, leaseId, StringComparison.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.LeaseReferenceMismatch);
|
||||
if (!lease.Roles.Contains(request.Role, StringComparer.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.RoleNotGranted);
|
||||
if (!request.Peer.Roles.Contains(request.Role, StringComparer.Ordinal)) return Decide(request, lease, CultMeshAuthorityDenialReason.RoleNotAdvertised);
|
||||
if (!string.IsNullOrWhiteSpace(request.ShardId) && lease.ShardIds.Count != 0 && !lease.ShardIds.Contains(request.ShardId!, StringComparer.Ordinal))
|
||||
return Decide(request, lease, CultMeshAuthorityDenialReason.ShardNotGranted);
|
||||
if (!string.IsNullOrWhiteSpace(request.ShardId) && request.Peer.ShardIds.Count != 0 && !request.Peer.ShardIds.Contains(request.ShardId!, StringComparer.Ordinal))
|
||||
return Decide(request, lease, CultMeshAuthorityDenialReason.ShardNotAdvertised);
|
||||
if (lease.AuthorityEpoch.Value != request.AuthorityEpoch) return Decide(request, lease, CultMeshAuthorityDenialReason.EpochMismatch);
|
||||
if (!string.IsNullOrWhiteSpace(request.ResourceScope) && !lease.ResourceScopes.Contains(request.ResourceScope!, StringComparer.Ordinal))
|
||||
return Decide(request, lease, CultMeshAuthorityDenialReason.ResourceScopeNotGranted);
|
||||
return Decide(request, lease, CultMeshAuthorityDenialReason.None);
|
||||
}
|
||||
|
||||
internal static CultMeshAuthorityResolver CreateDenyByDefault(ICultMeshAuthorityLeaseSource leases, ICultMeshClock? clock = null) =>
|
||||
new CultMeshAuthorityResolver(leases, DenyAllSignatures.Instance, NoRevocations.Instance, clock ?? CultMeshSystemClock.Instance);
|
||||
|
||||
private CultMeshAuthorityDecision Decide(CultMeshAuthorityRequest request, CultMeshAuthorityLease? lease, CultMeshAuthorityDenialReason reason)
|
||||
{
|
||||
var decision = new CultMeshAuthorityDecision(request, lease, reason);
|
||||
_diagnostics.Emit(new CultMeshDiagnosticEvent(
|
||||
Interlocked.Increment(ref _diagnosticSequence), _clock.UtcNow, CultMeshReliabilityOrgan.Authority,
|
||||
CultMeshDiagnosticKind.AuthorityDecision, lease?.LeaseId ?? request.Peer.AuthorityLeaseId ?? "lease:missing",
|
||||
request.Peer.PeerId, decision.IsAuthorized ? "authorized" : "denied", reason.ToString(), lease?.IssuerRuntimeId ?? "",
|
||||
schemaVersion: "gamecult.mesh.authority_decision.v1"));
|
||||
return decision;
|
||||
}
|
||||
|
||||
private sealed class DenyAllSignatures : ICultMeshAuthoritySignatureVerifier
|
||||
{
|
||||
public static DenyAllSignatures Instance { get; } = new DenyAllSignatures();
|
||||
public bool Verify(CultMeshAuthorityLease lease) => false;
|
||||
}
|
||||
|
||||
private sealed class NoRevocations : ICultMeshAuthorityRevocationSource
|
||||
{
|
||||
public static NoRevocations Instance { get; } = new NoRevocations();
|
||||
public bool IsRevoked(string leaseId, long authorityEpoch) => false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CultMeshAuthorityLeaseCatalog : ICultMeshAuthorityLeaseSource
|
||||
{
|
||||
private readonly Dictionary<string, CultMeshAuthorityLease> _leases = new(StringComparer.Ordinal);
|
||||
public IReadOnlyList<CultMeshAuthorityLease> Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
|
||||
public void Upsert(CultMeshAuthorityLease lease) { if (lease == null) throw new ArgumentNullException(nameof(lease)); _leases[lease.LeaseId] = lease; }
|
||||
public CultMeshAuthorityLease? Get(string leaseId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(leaseId)) throw new ArgumentException("Value must be non-empty.", nameof(leaseId));
|
||||
return _leases.TryGetValue(leaseId, out var lease) ? lease : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a peer card is authorized for a role and optional shard.
|
||||
/// </summary>
|
||||
[Obsolete("Authority decisions belong to CultMeshAuthorityResolver. This compatibility path denies unsigned and unverifiable leases.")]
|
||||
public bool IsAuthorized(CultMeshPeerCard peer, string role, string? shardId = null, DateTimeOffset? at = null)
|
||||
{
|
||||
if (peer == null) throw new ArgumentNullException(nameof(peer));
|
||||
if (string.IsNullOrWhiteSpace(peer.AuthorityLeaseId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var lease = string.IsNullOrWhiteSpace(peer.AuthorityLeaseId) ? null : Get(peer.AuthorityLeaseId!);
|
||||
var epoch = lease?.AuthorityEpoch ?? -1;
|
||||
if (epoch < 0) return false;
|
||||
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(this, at.HasValue ? new CatalogFixedClock(at.Value) : null);
|
||||
return resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, epoch)).IsAuthorized;
|
||||
}
|
||||
|
||||
return _leases.TryGetValue(peer.AuthorityLeaseId!, out var lease) &&
|
||||
lease.Covers(peer, role, shardId, at);
|
||||
private sealed class CatalogFixedClock : ICultMeshClock
|
||||
{
|
||||
public CatalogFixedClock(DateTimeOffset now) => UtcNow = now;
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
public System.Threading.Tasks.Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ namespace GameCult.Mesh
|
|||
/// </summary>
|
||||
public CultMeshAuthorityLeaseCatalog? AuthorityLeases { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sole authority resolver used by the session. When omitted, the
|
||||
/// session installs a deny-by-default resolver over <see cref="AuthorityLeases"/>.
|
||||
/// </summary>
|
||||
public CultMeshAuthorityResolver? AuthorityResolver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets simulation consensus options.
|
||||
/// </summary>
|
||||
|
|
@ -68,6 +74,7 @@ namespace GameCult.Mesh
|
|||
VerseCatalog = options.VerseCatalog ?? new CultMeshVerseCatalog();
|
||||
PeerCatalog = options.PeerCatalog ?? new CultMeshPeerCatalog();
|
||||
AuthorityLeases = options.AuthorityLeases ?? new CultMeshAuthorityLeaseCatalog();
|
||||
AuthorityResolver = options.AuthorityResolver ?? CultMeshAuthorityResolver.CreateDenyByDefault(AuthorityLeases);
|
||||
ObservationHub = new CultNetSimulationObservationHub(options.ConsensusOptions);
|
||||
FactCommitter = new CultMeshSimulationFactCommitter(Node.Database);
|
||||
|
||||
|
|
@ -117,6 +124,9 @@ namespace GameCult.Mesh
|
|||
/// </summary>
|
||||
public CultMeshAuthorityLeaseCatalog AuthorityLeases { get; }
|
||||
|
||||
/// <summary>Gets the session authority decision owner.</summary>
|
||||
public CultMeshAuthorityResolver AuthorityResolver { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the session simulation observation hub.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -192,23 +192,40 @@ namespace GameCult.Mesh
|
|||
}
|
||||
|
||||
/// <summary>Finds peers that advertise a Verse role and are authorized by the supplied lease catalog.</summary>
|
||||
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
|
||||
public IReadOnlyList<CultMeshPeerCard> FindAuthorized(
|
||||
string verseId,
|
||||
string role,
|
||||
CultMeshAuthorityLeaseCatalog leases,
|
||||
string? shardId = null,
|
||||
DateTimeOffset? at = null)
|
||||
{
|
||||
if (leases == null) throw new ArgumentNullException(nameof(leases));
|
||||
var resolver = CultMeshAuthorityResolver.CreateDenyByDefault(leases,
|
||||
at.HasValue ? new PeerCatalogFixedClock(at.Value) : null);
|
||||
return FindAuthorized(verseId, role, resolver, 0, shardId);
|
||||
}
|
||||
|
||||
/// <summary>Finds contact candidates accepted by the authority resolver.</summary>
|
||||
public IReadOnlyList<CultMeshPeerCard> FindAuthorized(
|
||||
string verseId,
|
||||
string role,
|
||||
CultMeshAuthorityResolver resolver,
|
||||
long authorityEpoch,
|
||||
string? shardId = null,
|
||||
string? resourceScope = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (string.IsNullOrWhiteSpace(verseId)) throw new ArgumentException("Value must be non-empty.", nameof(verseId));
|
||||
if (string.IsNullOrWhiteSpace(role)) throw new ArgumentException("Value must be non-empty.", nameof(role));
|
||||
if (leases == null) throw new ArgumentNullException(nameof(leases));
|
||||
if (resolver == null) throw new ArgumentNullException(nameof(resolver));
|
||||
return Find(verseId, role)
|
||||
.Where(peer => leases.IsAuthorized(peer, role, shardId, at))
|
||||
.Where(peer => resolver.Resolve(new CultMeshAuthorityRequest(peer, role, shardId, authorityEpoch, resourceScope)).IsAuthorized)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Returns the first peer that advertises a Verse role and is authorized by the supplied lease catalog.</summary>
|
||||
[Obsolete("Supply CultMeshAuthorityResolver and an explicit authority epoch. This compatibility path denies unverifiable leases.")]
|
||||
public CultMeshPeerCard? FirstAuthorized(
|
||||
string verseId,
|
||||
string role,
|
||||
|
|
@ -219,6 +236,18 @@ namespace GameCult.Mesh
|
|||
return FindAuthorized(verseId, role, leases, shardId, at).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Returns the first contact candidate accepted by the authority resolver.</summary>
|
||||
public CultMeshPeerCard? FirstAuthorized(
|
||||
string verseId,
|
||||
string role,
|
||||
CultMeshAuthorityResolver resolver,
|
||||
long authorityEpoch,
|
||||
string? shardId = null,
|
||||
string? resourceScope = null)
|
||||
{
|
||||
return FindAuthorized(verseId, role, resolver, authorityEpoch, shardId, resourceScope).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
@ -239,6 +268,13 @@ namespace GameCult.Mesh
|
|||
throw new ObjectDisposedException(nameof(CultMeshPeerCatalog));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PeerCatalogFixedClock : ICultMeshClock
|
||||
{
|
||||
public PeerCatalogFixedClock(DateTimeOffset utcNow) => UtcNow = utcNow;
|
||||
public DateTimeOffset UtcNow { get; }
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -104,6 +104,45 @@ must never confer authority.
|
|||
**Demotion:** an advertised peer card is only contact evidence. It is never an
|
||||
authorization result.
|
||||
|
||||
#### Phase 3 body map
|
||||
|
||||
**Owner:** `CultMeshAuthorityResolver` is the only component that decides
|
||||
whether a peer may act. Decisions are evaluated on demand and are never cached
|
||||
as grants.
|
||||
|
||||
**Inputs:** `CultMeshAuthorityRequest`, an `ICultMeshAuthorityLeaseSource`, an
|
||||
`ICultMeshAuthoritySignatureVerifier`, an
|
||||
`ICultMeshAuthorityRevocationSource`, the current authority epoch, and an
|
||||
injected `ICultMeshClock`.
|
||||
|
||||
**Outputs:** `CultMeshAuthorityDecision`, carrying either the accepted lease
|
||||
evidence identifiers or one typed denial reason. Authority diagnostics carry
|
||||
lease, peer, and issuer identifiers but never signature material.
|
||||
|
||||
**Derived state:** peer selection is a projection over contact candidates and
|
||||
fresh resolver decisions. The lease catalog is only a mutable evidence source;
|
||||
the peer catalog is only a contact source.
|
||||
|
||||
**Forbidden writers:** lease replacement, peer-card publication, successful
|
||||
transport connection, cached presence, and wall-clock defaults cannot grant or
|
||||
restore authority. Revocation and epoch policy remain outside both catalogs.
|
||||
|
||||
**Shared paths:** gameplay sessions expose their resolver; peer selection and
|
||||
both privileged RUDP creation paths call that resolver. Compatibility overloads
|
||||
delegate to the same resolver with deny-by-default signature policy.
|
||||
|
||||
**Cut line:** `CultMeshAuthorityLease.Covers`,
|
||||
`CultMeshAuthorityLeaseCatalog.IsAuthorized`, and catalog-owned
|
||||
`FindAuthorized`/`FirstAuthorized` no longer contain authorization logic.
|
||||
Version-zero or unsigned leases are not reinterpreted as trusted epoch-zero
|
||||
leases. Callers must supply a version-one lease, explicit epoch, signature
|
||||
verifier, and revocation source.
|
||||
|
||||
**Compatibility inventory:** the old lease/catalog peer-selection and RUDP
|
||||
overloads remain source-compatible for migration, but cannot authorize because
|
||||
they have no signature-verification authority. They delegate to a
|
||||
deny-by-default resolver and should be replaced by resolver-taking overloads.
|
||||
|
||||
### Content transfer scheduler
|
||||
|
||||
**Owner:** `CultMeshContentTransferService`.
|
||||
|
|
|
|||
208
tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs
Normal file
208
tests/GameCult.Mesh.Tests/CultMeshAuthorityResolverTests.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Mesh.Tests;
|
||||
|
||||
public sealed class CultMeshAuthorityResolverTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 13, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Test]
|
||||
public void Resolve_AcceptsSignedLeaseWithExactScopeAndEpoch()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var decision = fixture.Resolver.Resolve(fixture.Request());
|
||||
|
||||
decision.IsAuthorized.Should().BeTrue();
|
||||
decision.DenialReason.Should().Be(CultMeshAuthorityDenialReason.None);
|
||||
decision.LeaseId.Should().Be("lease:pilot");
|
||||
decision.IssuerRuntimeId.Should().Be("odin");
|
||||
}
|
||||
|
||||
[TestCase(null, CultMeshAuthorityDenialReason.MissingSignature)]
|
||||
[TestCase("bad-signature", CultMeshAuthorityDenialReason.InvalidSignature)]
|
||||
public void Resolve_RejectsMissingOrInvalidSignature(string? signature, CultMeshAuthorityDenialReason expected)
|
||||
{
|
||||
var fixture = CreateFixture(signature: signature);
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_RejectsLegacyUnsignedLeaseInsteadOfTreatingItAsEpochZero()
|
||||
{
|
||||
var catalog = new CultMeshAuthorityLeaseCatalog();
|
||||
var lease = new CultMeshAuthorityLease("legacy", "verse", "peer", new[] { "pilot" }, null, "odin", Now.AddMinutes(-1), Now.AddMinutes(1));
|
||||
catalog.Upsert(lease);
|
||||
var peer = new CultMeshPeerCard("peer", "verse", Array.Empty<string>(), new[] { "pilot" }, authorityLeaseId: "legacy");
|
||||
var resolver = new CultMeshAuthorityResolver(catalog, new SignatureVerifier(), new Revocations(), new ManualClock(Now));
|
||||
|
||||
resolver.Resolve(new CultMeshAuthorityRequest(peer, "pilot", null, 0)).DenialReason
|
||||
.Should().Be(CultMeshAuthorityDenialReason.UnsupportedLeaseVersion);
|
||||
#pragma warning disable CS0618
|
||||
catalog.IsAuthorized(peer, "pilot", at: Now).Should().BeFalse();
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_UsesInjectedClockForNotYetValidAndExpiry()
|
||||
{
|
||||
var fixture = CreateFixture(validFrom: Now.AddMinutes(1), expiresAt: Now.AddMinutes(2));
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.NotYetValid);
|
||||
fixture.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
fixture.Resolver.Resolve(fixture.Request()).IsAuthorized.Should().BeTrue();
|
||||
fixture.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Expired);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_RejectsRevokedLeaseAndCatalogReplacementCannotReviveIt()
|
||||
{
|
||||
var fixture = CreateFixture(revoked: true);
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Revoked);
|
||||
|
||||
fixture.Catalog.Upsert(CreateLease(signature: "valid-replacement"));
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Revoked);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_CatalogReplacementCannotOverrideEpochPolicy()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
fixture.Catalog.Upsert(new CultMeshAuthorityLease(
|
||||
"lease:pilot", "verse:aetheria", "peer:pilot", new[] { "pilot" }, new[] { "shard:a" }, "odin",
|
||||
Now.AddMinutes(-1), Now.AddMinutes(1), "valid-new-epoch", 8, new[] { "body:world" }));
|
||||
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.EpochMismatch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ClockMovementExpiresLeaseWithoutCatalogMutation()
|
||||
{
|
||||
var fixture = CreateFixture(expiresAt: Now.AddSeconds(1));
|
||||
fixture.Resolver.Resolve(fixture.Request()).IsAuthorized.Should().BeTrue();
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
fixture.Resolver.Resolve(fixture.Request()).DenialReason.Should().Be(CultMeshAuthorityDenialReason.Expired);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_ReportsDistinctScopeDenials()
|
||||
{
|
||||
AssertDenied(CreateFixture(peerVerse: "other").Request(), CreateFixture(peerVerse: "other").Resolver, CultMeshAuthorityDenialReason.VerseMismatch);
|
||||
AssertDenied(CreateFixture(peerId: "other").Request(), CreateFixture(peerId: "other").Resolver, CultMeshAuthorityDenialReason.PeerMismatch);
|
||||
AssertDenied(CreateFixture(requestRole: "trade").Request(), CreateFixture(requestRole: "trade").Resolver, CultMeshAuthorityDenialReason.RoleNotGranted);
|
||||
AssertDenied(CreateFixture(requestShard: "shard:b").Request(), CreateFixture(requestShard: "shard:b").Resolver, CultMeshAuthorityDenialReason.ShardNotGranted);
|
||||
AssertDenied(CreateFixture(requestEpoch: 8).Request(), CreateFixture(requestEpoch: 8).Resolver, CultMeshAuthorityDenialReason.EpochMismatch);
|
||||
AssertDenied(CreateFixture(requestScope: "body:other").Request(), CreateFixture(requestScope: "body:other").Resolver, CultMeshAuthorityDenialReason.ResourceScopeNotGranted);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_NewPeerPublicationCannotReviveExpiredAuthority()
|
||||
{
|
||||
var fixture = CreateFixture(expiresAt: Now);
|
||||
fixture.CatalogPeers.Upsert(fixture.Peer);
|
||||
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
|
||||
|
||||
fixture.CatalogPeers.Upsert(new CultMeshPeerCard("peer:pilot", "verse:aetheria", Array.Empty<string>(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot"));
|
||||
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_NewPeerPublicationCannotReviveRevokedAuthority()
|
||||
{
|
||||
var fixture = CreateFixture(revoked: true);
|
||||
fixture.CatalogPeers.Upsert(fixture.Peer);
|
||||
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
|
||||
|
||||
fixture.CatalogPeers.Upsert(new CultMeshPeerCard("peer:pilot", "verse:aetheria", Array.Empty<string>(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot"));
|
||||
fixture.CatalogPeers.FindAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Diagnostics_CarryEvidenceIdentifiersButNeverSignatureMaterial()
|
||||
{
|
||||
var fixture = CreateFixture(signature: "super-secret-signature");
|
||||
fixture.Resolver.Resolve(fixture.Request());
|
||||
|
||||
var diagnostic = fixture.Diagnostics.Snapshot().Should().ContainSingle().Subject;
|
||||
diagnostic.OperationId.Should().Be("lease:pilot");
|
||||
diagnostic.SubjectId.Should().Be("peer:pilot");
|
||||
diagnostic.SourceId.Should().Be("odin");
|
||||
string.Join("|", diagnostic.OperationId, diagnostic.SubjectId, diagnostic.SourceId, diagnostic.ReasonCode, diagnostic.Endpoint)
|
||||
.Should().NotContain("super-secret-signature");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PeerSelectionAndPrivilegedRudpHelpersUseResolverDecision()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
fixture.CatalogPeers.Upsert(fixture.Peer);
|
||||
fixture.CatalogPeers.FirstAuthorized("verse:aetheria", "pilot", fixture.Resolver, 7, "shard:a", "body:world")
|
||||
.Should().BeSameAs(fixture.Peer);
|
||||
|
||||
#pragma warning disable CS0618
|
||||
fixture.CatalogPeers.FirstAuthorized("verse:aetheria", "pilot", fixture.Catalog, "shard:a", Now).Should().BeNull();
|
||||
Action legacy = () => CultMesh.CreateRudpClientForAuthorizedPeer("client", 1, fixture.CatalogPeers, fixture.Catalog, "verse:aetheria", "pilot", "shard:a", Now);
|
||||
#pragma warning restore CS0618
|
||||
legacy.Should().Throw<InvalidOperationException>().WithMessage("No authorized RUDP peer*");
|
||||
|
||||
Action resolved = () => CultMesh.CreateRudpClientForAuthorizedPeer("client", 1, fixture.CatalogPeers, fixture.Resolver, 7, "verse:aetheria", "pilot", "shard:a", "body:world");
|
||||
resolved.Should().Throw<InvalidOperationException>().WithMessage("Peer peer:pilot does not advertise a RUDP endpoint.");
|
||||
}
|
||||
|
||||
private static void AssertDenied(CultMeshAuthorityRequest request, CultMeshAuthorityResolver resolver, CultMeshAuthorityDenialReason reason) =>
|
||||
resolver.Resolve(request).DenialReason.Should().Be(reason);
|
||||
|
||||
private static Fixture CreateFixture(
|
||||
string? signature = "valid-signature", DateTimeOffset? validFrom = null, DateTimeOffset? expiresAt = null,
|
||||
bool revoked = false, string peerId = "peer:pilot", string peerVerse = "verse:aetheria",
|
||||
string requestRole = "pilot", string requestShard = "shard:a", long requestEpoch = 7, string requestScope = "body:world")
|
||||
{
|
||||
var catalog = new CultMeshAuthorityLeaseCatalog();
|
||||
catalog.Upsert(CreateLease(signature, validFrom, expiresAt));
|
||||
var clock = new ManualClock(Now);
|
||||
var revocations = new Revocations(revoked ? new[] { ("lease:pilot", 7L) } : null);
|
||||
var diagnostics = new CultMeshDiagnosticBuffer();
|
||||
var resolver = new CultMeshAuthorityResolver(catalog, new SignatureVerifier(), revocations, clock, diagnostics);
|
||||
var peer = new CultMeshPeerCard(peerId, peerVerse, Array.Empty<string>(), new[] { "pilot" }, new[] { "shard:a" }, authorityLeaseId: "lease:pilot");
|
||||
return new Fixture(catalog, new CultMeshPeerCatalog(), clock, diagnostics, resolver, peer, requestRole, requestShard, requestEpoch, requestScope);
|
||||
}
|
||||
|
||||
private static CultMeshAuthorityLease CreateLease(string? signature = "valid-signature", DateTimeOffset? validFrom = null, DateTimeOffset? expiresAt = null) =>
|
||||
new("lease:pilot", "verse:aetheria", "peer:pilot", new[] { "pilot" }, new[] { "shard:a" }, "odin",
|
||||
validFrom ?? Now.AddMinutes(-1), expiresAt ?? Now.AddMinutes(1), signature, 7, new[] { "body:world" });
|
||||
|
||||
private sealed record Fixture(
|
||||
CultMeshAuthorityLeaseCatalog Catalog, CultMeshPeerCatalog CatalogPeers, ManualClock Clock,
|
||||
CultMeshDiagnosticBuffer Diagnostics, CultMeshAuthorityResolver Resolver, CultMeshPeerCard Peer,
|
||||
string Role, string Shard, long Epoch, string Scope)
|
||||
{
|
||||
public CultMeshAuthorityRequest Request() => new(Peer, Role, Shard, Epoch, Scope);
|
||||
}
|
||||
|
||||
private sealed class SignatureVerifier : ICultMeshAuthoritySignatureVerifier
|
||||
{
|
||||
public bool Verify(CultMeshAuthorityLease lease) => lease.Signature?.StartsWith("valid", StringComparison.Ordinal) == true;
|
||||
}
|
||||
|
||||
private sealed class Revocations : ICultMeshAuthorityRevocationSource
|
||||
{
|
||||
private readonly HashSet<(string, long)> _revoked;
|
||||
public Revocations(IEnumerable<(string, long)>? revoked = null) => _revoked = revoked?.ToHashSet() ?? new();
|
||||
public bool IsRevoked(string leaseId, long authorityEpoch) => _revoked.Contains((leaseId, authorityEpoch));
|
||||
}
|
||||
|
||||
private sealed class ManualClock : ICultMeshClock
|
||||
{
|
||||
public ManualClock(DateTimeOffset now) => UtcNow = now;
|
||||
public DateTimeOffset UtcNow { get; private set; }
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) { Advance(delay); return Task.CompletedTask; }
|
||||
public void Advance(TimeSpan duration) => UtcNow += duration;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue