Add CultMesh authority lease checks
This commit is contained in:
parent
46cb4820ff
commit
e5e993e7aa
7 changed files with 211 additions and 0 deletions
|
|
@ -204,6 +204,14 @@ namespace GameCult.Mesh
|
|||
return new CultMeshPeerCatalog();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a local authority lease catalog.
|
||||
/// </summary>
|
||||
public static CultMeshAuthorityLeaseCatalog CreateAuthorityLeaseCatalog()
|
||||
{
|
||||
return new CultMeshAuthorityLeaseCatalog();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches Verse discovery responses to a CultMesh node.
|
||||
/// </summary>
|
||||
|
|
|
|||
140
src/GameCult.Mesh/CultMeshAuthority.cs
Normal file
140
src/GameCult.Mesh/CultMeshAuthority.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
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 CultMeshAuthorityLease(
|
||||
string leaseId,
|
||||
string verseId,
|
||||
string peerId,
|
||||
IEnumerable<string> roles,
|
||||
IEnumerable<string>? shardIds,
|
||||
string issuerRuntimeId,
|
||||
DateTimeOffset validFrom,
|
||||
DateTimeOffset expiresAt,
|
||||
string? signature = 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;
|
||||
if (expiresAt <= validFrom)
|
||||
{
|
||||
throw new ArgumentException("Lease expiry must be after its valid-from timestamp.", nameof(expiresAt));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this lease is valid at the supplied time.
|
||||
/// </summary>
|
||||
public bool IsValidAt(DateTimeOffset at)
|
||||
{
|
||||
return at >= ValidFrom && at < ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this lease covers a peer card, role, and shard.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
private static string[] Clean(IEnumerable<string>? values)
|
||||
{
|
||||
return values?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
||||
?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static string RequireNonEmpty(string value, string paramName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value must be non-empty.", paramName)
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local authority lease catalog.
|
||||
/// </summary>
|
||||
public sealed class CultMeshAuthorityLeaseCatalog
|
||||
{
|
||||
private readonly Dictionary<string, CultMeshAuthorityLease> _leases = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Gets all known leases.</summary>
|
||||
public IReadOnlyList<CultMeshAuthorityLease> Leases => _leases.Values.OrderBy(lease => lease.LeaseId, StringComparer.Ordinal).ToArray();
|
||||
|
||||
/// <summary>Adds or replaces an authority lease.</summary>
|
||||
public void Upsert(CultMeshAuthorityLease lease)
|
||||
{
|
||||
if (lease == null) throw new ArgumentNullException(nameof(lease));
|
||||
_leases[lease.LeaseId] = lease;
|
||||
}
|
||||
|
||||
/// <summary>Gets a lease by id, if known.</summary>
|
||||
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>
|
||||
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;
|
||||
}
|
||||
|
||||
return _leases.TryGetValue(peer.AuthorityLeaseId!, out var lease) &&
|
||||
lease.Covers(peer, role, shardId, at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,20 @@ Peer cards are contact candidates, not authority by themselves. Public Verses
|
|||
should validate authority leases or signatures before trusting a peer for
|
||||
committed state.
|
||||
|
||||
```csharp
|
||||
var leases = CultMesh.CreateAuthorityLeaseCatalog();
|
||||
leases.Upsert(new CultMeshAuthorityLease(
|
||||
"lease:players-us-east:42",
|
||||
"aetheria-main",
|
||||
"gc-us-east-1",
|
||||
[CultMeshPeerRoles.ShardPrimary],
|
||||
["players-us-east"],
|
||||
"gamecult-operator",
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
DateTimeOffset.UtcNow.AddMinutes(10),
|
||||
signature: "operator-signature"));
|
||||
```
|
||||
|
||||
Clients can fetch more peers from known peers:
|
||||
|
||||
```csharp
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Namespace: `GameCult.Mesh`
|
|||
- `CultMesh.CreatePeerCatalog()`
|
||||
- `CultMesh.CreateVerseDiscoveryClient(...)`
|
||||
- `CultMesh.CreatePeerExchangeClient(...)`
|
||||
- `CultMesh.CreateAuthorityLeaseCatalog()`
|
||||
- `CultMesh.CreateClient(...)`
|
||||
- `CultMesh.ConnectClient(...)`
|
||||
|
||||
|
|
@ -66,6 +67,14 @@ upsert them into a local catalog.
|
|||
Peer cards are discovery hints; authority still requires a valid lease or
|
||||
signature for the target Verse.
|
||||
|
||||
### Authority Leases
|
||||
|
||||
`CultMeshAuthorityLease` grants one peer specific roles for a Verse, optionally
|
||||
scoped to shard ids and bounded by time. `CultMeshAuthorityLeaseCatalog` checks
|
||||
whether a peer card is authorized for a requested role and shard. This keeps
|
||||
peer exchange separate from authority instead of letting contact gossip mutate
|
||||
the world.
|
||||
|
||||
### Shard Authority
|
||||
|
||||
Each shard has one primary writer for now. Non-primary writes are rejected or
|
||||
|
|
|
|||
|
|
@ -64,3 +64,8 @@ primary shard, replica, read replica, and simulation observer roles, but those
|
|||
cards do not grant authority by themselves. Public operator Verses should treat
|
||||
authority lease ids and signatures as mandatory before accepting committed
|
||||
state from a peer.
|
||||
|
||||
Authority leases are the narrow bridge between discovery and trust. A lease
|
||||
binds a peer id to a Verse, role, shard scope, issuer, validity window, and
|
||||
signature. Peer exchange can spread cards quickly; lease validation decides
|
||||
whether a card is allowed to touch committed state.
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ Implemented:
|
|||
- schema-v0 peer exchange request/response messages
|
||||
- CultMesh peer cards, peer catalog, peer exchange server bridge, and peer
|
||||
exchange client
|
||||
- CultMesh authority leases and lease catalog checks for peer role/shard
|
||||
authorization
|
||||
- client-authority scopes for locally predicted input documents
|
||||
- predicted and reconciled database change kinds for client-side prediction
|
||||
- simulation witness observations for mesh-side opinions about frame facts
|
||||
|
|
@ -123,12 +125,16 @@ Not implemented yet:
|
|||
until a shard owner writes them into the log.
|
||||
- Peer cards are contact candidates. They can advertise roles and authority
|
||||
lease ids, but they do not grant authority without Verse-specific validation.
|
||||
- Authority leases bind peer id, Verse id, roles, optional shard scope, issuer,
|
||||
validity window, and signature. They are the first trust boundary above peer
|
||||
exchange.
|
||||
|
||||
## Next Coherent Slice
|
||||
|
||||
Add discovery fanout and operational polish:
|
||||
|
||||
- add periodic gossip fanout over known peer cards
|
||||
- add real signature verification for authority leases
|
||||
- add snapshot throttling and size limits for public mesh edges
|
||||
- after that, add simulation-frame rollback/resimulation helpers around
|
||||
predicted input streams
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,35 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(updates, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultMeshAuthorityLeaseCatalog_Authorizes_PeerRoleAndShard()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00.0000000Z");
|
||||
var catalog = CultMesh.CreateAuthorityLeaseCatalog();
|
||||
var peer = new CultMeshPeerCard(
|
||||
"peer-primary",
|
||||
"aetheria-main",
|
||||
["cultnet://primary.example.test:3075"],
|
||||
roles: [CultMeshPeerRoles.ShardPrimary],
|
||||
shardIds: ["players-us-east"],
|
||||
authorityLeaseId: "lease-primary");
|
||||
catalog.Upsert(new CultMeshAuthorityLease(
|
||||
"lease-primary",
|
||||
"aetheria-main",
|
||||
"peer-primary",
|
||||
[CultMeshPeerRoles.ShardPrimary],
|
||||
["players-us-east"],
|
||||
"gc-operator",
|
||||
now.AddMinutes(-5),
|
||||
now.AddMinutes(5),
|
||||
signature: "sig"));
|
||||
|
||||
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now), Is.True);
|
||||
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-eu", now), Is.False);
|
||||
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ReadReplica, "players-us-east", now), Is.False);
|
||||
Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now.AddMinutes(6)), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue