CultLib/src/GameCult.Mesh/CultMeshAuthority.cs
2026-07-13 14:00:26 +01:00

271 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace GameCult.Mesh
{
public sealed class CultMeshAuthorityLease
{
public const string CurrentSchemaVersion = "gamecult.mesh.authority_lease.v1";
public const string LegacySchemaVersion = "gamecult.mesh.authority_lease.v0";
public CultMeshAuthorityLease(
string leaseId,
string verseId,
string peerId,
IEnumerable<string> roles,
IEnumerable<string>? shardIds,
string issuerRuntimeId,
DateTimeOffset validFrom,
DateTimeOffset expiresAt,
string? signature = null,
long? authorityEpoch = null,
IEnumerable<string>? resourceScopes = null,
string? schemaVersion = null)
{
LeaseId = RequireNonEmpty(leaseId, nameof(leaseId));
VerseId = RequireNonEmpty(verseId, nameof(verseId));
PeerId = RequireNonEmpty(peerId, nameof(peerId));
Roles = Clean(roles);
ShardIds = Clean(shardIds);
IssuerRuntimeId = RequireNonEmpty(issuerRuntimeId, nameof(issuerRuntimeId));
ValidFrom = validFrom;
ExpiresAt = expiresAt;
Signature = signature;
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));
}
public string LeaseId { get; }
public string VerseId { get; }
public string PeerId { get; }
public IReadOnlyList<string> Roles { get; }
public IReadOnlyList<string> ShardIds { get; }
public string IssuerRuntimeId { get; }
public DateTimeOffset ValidFrom { get; }
public DateTimeOffset ExpiresAt { get; }
public string? Signature { get; }
public long? AuthorityEpoch { get; }
public IReadOnlyList<string> ResourceScopes { get; }
public string SchemaVersion { get; }
public bool IsValidAt(DateTimeOffset at) => at >= ValidFrom && at < ExpiresAt;
[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));
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) =>
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
{
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 sealed class FixedClock : ICultMeshClock
{
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;
}
}
public interface ICultMeshAuthorityLeaseSource
{
CultMeshAuthorityLease? Get(string leaseId);
}
public interface ICultMeshAuthoritySignatureVerifier
{
bool Verify(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)
{
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;
}
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;
}
[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));
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;
}
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;
}
}
}