diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs
index 668c088e..d9e7ec09 100644
--- a/src/GameCult.Networking/CultNetDatabase.cs
+++ b/src/GameCult.Networking/CultNetDatabase.cs
@@ -1015,11 +1015,16 @@ namespace GameCult.Networking
}
///
- /// Applies a raw document mutation after checking shard authority.
+ /// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's):
+ /// it asks no remote write authority. A peer's put goes through , which does.
///
/// No shard owns the schema and key; nothing was written.
/// The change committed and was published, but the shard log could not record it.
- public async Task ApplyPutAsync(CultNetDocumentPutRawMessage message)
+ public Task ApplyPutAsync(CultNetDocumentPutRawMessage message) => ApplyPutAsync(message, authorize: null);
+
+ // The peer door: the same apply, with the server's authorize callback asked once the schema is resolved and before the
+ // shard is. The callback throws to refuse. The public overload is the host's own door and asks no remote authority.
+ internal async Task ApplyPutAsync(CultNetDocumentPutRawMessage message, Action? authorize)
{
ThrowIfDisposed();
if (message == null) throw new ArgumentNullException(nameof(message));
@@ -1030,6 +1035,7 @@ namespace GameCult.Networking
var key = new CultRecordKey(message.Document.RecordKey);
var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document);
+ authorize?.Invoke(descriptor.SchemaId, key);
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
var document = _documents.DeserializeRawDocument(message.Document);
@@ -1039,17 +1045,22 @@ namespace GameCult.Networking
}
///
- /// Applies a raw document delete after checking shard authority.
+ /// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote
+ /// write authority. A peer's delete goes through , which does.
///
/// No shard owns the schema and key; nothing was written.
/// The change committed and was published, but the shard log could not record it.
- public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message)
+ public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) => ApplyDeleteAsync(message, authorize: null);
+
+ // The peer door; see the put overload.
+ internal Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message, Action? authorize)
{
ThrowIfDisposed();
if (message == null) throw new ArgumentNullException(nameof(message));
var key = new CultRecordKey(message.RecordKey);
var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId);
+ authorize?.Invoke(descriptor.SchemaId, key);
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
var previous = _cache.Get(key);
diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs
index f5bbb464..149b9619 100644
--- a/src/GameCult.Networking/CultNetDatabaseServer.cs
+++ b/src/GameCult.Networking/CultNetDatabaseServer.cs
@@ -288,11 +288,52 @@ namespace GameCult.Networking
return Task.CompletedTask;
}
+ // The one question asked of the write authority per received write. None configured refuses; an authority that throws
+ // refuses too (fail closed) and the fault is logged as an error.
+ private Action Authorize(
+ CultNetServerPeer peer,
+ CultNetRemoteWriteOperation operation,
+ string? shardId,
+ long? shardEpoch)
+ {
+ return (schemaId, key) =>
+ {
+ var request = new CultNetRemoteWriteRequest(peer, operation, schemaId, key, shardId, shardEpoch);
+ var authority = _options.RemoteWriteAuthority;
+ if (authority == null)
+ {
+ throw new CultNetRemoteWriteRefusedException(request, "no remote write authority is configured");
+ }
+
+ CultNetRemoteWriteDecision decision;
+ try
+ {
+ decision = authority.Decide(request);
+ }
+ catch (Exception ex)
+ {
+ _server.Logger.LogError($"CultNet remote write authority failed: {ex.Message}");
+ throw new CultNetRemoteWriteRefusedException(request, "the write authority failed");
+ }
+
+ if (decision == null || !decision.IsGranted)
+ {
+ throw new CultNetRemoteWriteRefusedException(request, decision?.Reason);
+ }
+ };
+ }
+
private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer)
{
try
{
- await _database.ApplyPutAsync(message).ConfigureAwait(false);
+ await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch))
+ .ConfigureAwait(false);
+ }
+ catch (CultNetRemoteWriteRefusedException ex)
+ {
+ _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
+ peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
}
catch (CultNetShardAuthorityException ex)
{
@@ -323,7 +364,13 @@ namespace GameCult.Networking
{
try
{
- await _database.ApplyDeleteAsync(message).ConfigureAwait(false);
+ await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch))
+ .ConfigureAwait(false);
+ }
+ catch (CultNetRemoteWriteRefusedException ex)
+ {
+ _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}");
+ peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
}
catch (CultNetShardAuthorityException ex)
{
diff --git a/src/GameCult.Networking/CultNetRemoteWriteAuthority.cs b/src/GameCult.Networking/CultNetRemoteWriteAuthority.cs
new file mode 100644
index 00000000..b3083f19
--- /dev/null
+++ b/src/GameCult.Networking/CultNetRemoteWriteAuthority.cs
@@ -0,0 +1,113 @@
+using System;
+using GameCult.Caching;
+
+namespace GameCult.Networking
+{
+ ///
+ /// Decides whether a connected peer may put or delete a record in the database a
+ /// serves. Transport admission (the connection key, a completed handshake) is
+ /// never a grant; this is the one owner of that decision.
+ ///
+ public interface ICultNetRemoteWriteAuthority
+ {
+ ///
+ /// Decides one peer write. Called once per received put or delete, after the schema is resolved and before the
+ /// shard is, so a refusal applies, publishes, logs and forwards nothing.
+ ///
+ CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request);
+ }
+
+ ///
+ /// The kind of write a peer asked for.
+ ///
+ public enum CultNetRemoteWriteOperation
+ {
+ /// Put a record.
+ Put,
+ /// Delete a record.
+ Delete
+ }
+
+ ///
+ /// One peer write, as the authority sees it.
+ ///
+ public sealed class CultNetRemoteWriteRequest
+ {
+ ///
+ /// Creates a request.
+ ///
+ public CultNetRemoteWriteRequest(
+ CultNetServerPeer peer,
+ CultNetRemoteWriteOperation operation,
+ string schemaId,
+ CultRecordKey recordKey,
+ string? shardId,
+ long? shardEpoch)
+ {
+ Peer = peer;
+ Operation = operation;
+ SchemaId = schemaId;
+ RecordKey = recordKey;
+ ShardId = shardId;
+ ShardEpoch = shardEpoch;
+ }
+
+ /// Gets the peer connection the message arrived on (endpoint and transport only).
+ public CultNetServerPeer Peer { get; }
+
+ /// Gets the operation.
+ public CultNetRemoteWriteOperation Operation { get; }
+
+ /// Gets the canonical schema id the database resolved for the write, never the alias the peer sent.
+ public string SchemaId { get; }
+
+ /// Gets the record key.
+ public CultRecordKey RecordKey { get; }
+
+ /// Gets the shard id as the peer sent it.
+ public string? ShardId { get; }
+
+ /// Gets the shard epoch as the peer sent it.
+ public long? ShardEpoch { get; }
+ }
+
+ ///
+ /// An authority's answer to one .
+ ///
+ public sealed class CultNetRemoteWriteDecision
+ {
+ private CultNetRemoteWriteDecision(bool isGranted, string? reason)
+ {
+ IsGranted = isGranted;
+ Reason = reason;
+ }
+
+ /// Gets whether the write may proceed.
+ public bool IsGranted { get; }
+
+ /// Gets the refusal reason, when there is one.
+ public string? Reason { get; }
+
+ /// Grants the write.
+ public static CultNetRemoteWriteDecision Grant() => new(true, null);
+
+ /// Refuses the write.
+ public static CultNetRemoteWriteDecision Refuse(string? reason = null) => new(false, reason);
+ }
+
+ // Thrown by the server's authorize callback through the database's remote apply when the authority refuses; the
+ // server's handlers are its one catcher.
+ internal sealed class CultNetRemoteWriteRefusedException : Exception
+ {
+ public CultNetRemoteWriteRefusedException(CultNetRemoteWriteRequest request, string? reason)
+ : base(reason ?? "the write was not authorized")
+ {
+ Request = request;
+ Reason = reason;
+ }
+
+ public CultNetRemoteWriteRequest Request { get; }
+
+ public string? Reason { get; }
+ }
+}
diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs
index e7f4cfd9..baf0b6d5 100644
--- a/src/GameCult.Networking/CultNetSchemaMessages.cs
+++ b/src/GameCult.Networking/CultNetSchemaMessages.cs
@@ -511,7 +511,7 @@ namespace GameCult.Networking
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
///
/// Gets or sets the machine-readable refusal code (R-N), e.g. selection_invalid ,
- /// cursor_stale , cursor_invalid , reference_outside_target , unowned_schema . Additive to
+ /// cursor_stale , cursor_invalid , reference_outside_target , unowned_schema , write_not_authorized . Additive to
/// cultnet.error.v0 ; a v0 peer that does not know this field ignores it.
///
[Key("code")] public string? Code { get; set; }
@@ -543,6 +543,18 @@ namespace GameCult.Networking
Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId }
};
+ ///
+ /// Builds the wire error for a peer write its authority refused: code write_not_authorized , details
+ /// { field: "recordKey", value } .
+ ///
+ public static CultNetErrorMessage ForRemoteWriteRefused(CultNetRemoteWriteRequest request, string? reason) =>
+ new CultNetErrorMessage
+ {
+ Error = string.IsNullOrWhiteSpace(reason) ? "write_not_authorized" : $"write_not_authorized: {reason}",
+ Code = "write_not_authorized",
+ Details = new CultNetErrorDetails { Field = "recordKey", Value = request.RecordKey.Value }
+ };
+
///
/// Builds the wire error for a cursor refusal (R-N): code cursor_stale with details
/// { asOf, current } , or code cursor_invalid with no details.
diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs
index b2b9e7a2..7038b0a7 100644
--- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs
+++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs
@@ -34,6 +34,13 @@ namespace GameCult.Networking
/// Gets or sets the forwarder used when non-primary write forwarding is enabled.
///
public ICultNetShardWriteForwarder? WriteForwarder { get; set; }
+
+ ///
+ /// Gets or sets the authority asked for every peer put and delete. None configured refuses them all: a connection
+ /// key admits a peer to the transport, never to write. A write forwarded to a primary is judged here for the
+ /// original peer and again at the primary for the forwarder's connection.
+ ///
+ public ICultNetRemoteWriteAuthority? RemoteWriteAuthority { get; set; }
}
///
diff --git a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
index 6285d674..eb68b1fc 100644
--- a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
+++ b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
@@ -733,7 +733,7 @@ namespace GameCult.Networking.Tests
using var server = new Server(cache, ServerSecurityOptions.Development());
var logger = new CapturingLogger();
server.Logger = logger;
- using var databaseServer = new CultNetDatabaseServer(server, database);
+ using var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = new TestGrantsEverything() });
var handler = (Func)typeof(CultNetDatabaseServer)
.GetField("_putHandler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(databaseServer)!;
@@ -929,7 +929,7 @@ namespace GameCult.Networking.Tests
var host = new Server(cache, ServerSecurityOptions.Development());
var logger = new CapturingLogger();
host.Logger = logger;
- var databaseServer = new CultNetDatabaseServer(host, database);
+ var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = new TestGrantsEverything() });
var peer = (CultNetServerPeer)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer));
const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
var put = (Func)typeof(CultNetDatabaseServer).GetField("_putHandler", flags)!.GetValue(databaseServer)!;
diff --git a/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs b/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs
new file mode 100644
index 00000000..198abb6d
--- /dev/null
+++ b/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs
@@ -0,0 +1,280 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using GameCult.Caching;
+using NUnit.Framework;
+using static GameCult.Networking.Tests.NetworkingTests;
+
+namespace GameCult.Networking.Tests
+{
+ // A peer's put or delete is applied or forwarded only when the server's RemoteWriteAuthority grants it. The wire handlers
+ // are reached through their private delegates with an uninitialized peer, as CultNetJournalTests.WireHandlers does:
+ // anything the handler tries to send throws on that peer, so a handler that throws answered the peer with a refusal and a
+ // handler that returns quietly sent nothing.
+ // The tests' own grant-everyone authority, for tests whose subject is not the grant. The library ships none.
+ internal sealed class TestGrantsEverything : ICultNetRemoteWriteAuthority
+ {
+ public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) => CultNetRemoteWriteDecision.Grant();
+ }
+
+ public sealed class RemoteWriteAuthorityTests
+ {
+ private const string ShardId = "authority-shard";
+ private static readonly CultRecordKey KeyA = new("tests:authority:a");
+ private static readonly CultRecordKey KeyB = new("tests:authority:b");
+ private static readonly CultRecordKey Unowned = new("unrelated:authority");
+
+ private static NetworkSchemaNote Note(string text) => new() { Schema = "tests.networking_note.v1", Text = text };
+
+ private sealed class Rig
+ {
+ public CultCache Cache = null!;
+ public CultNetDatabase Database = null!;
+ public CultNetDatabaseServer DatabaseServer = null!;
+ public Server Host = null!;
+ public CultNetServerPeer Peer = null!;
+ public CapturingLogger Logger = null!;
+ public Func Put = null!;
+ public Func Delete = null!;
+ public string SchemaId = string.Empty;
+ public List Published = new();
+ public IDisposable Subscription = null!;
+
+ public string? TextOf(CultRecordKey key) => Cache.Get(key)?.Text;
+ public int LogLength => Database.GetMutationLog(ShardId).Count;
+
+ public CultNetDocumentPutRawMessage PutOf(CultRecordKey key, string text)
+ {
+ var message = Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(key), Note(text));
+ message.ShardId = ShardId;
+ message.ShardEpoch = 1;
+ return message;
+ }
+
+ public CultNetDocumentDeleteMessage DeleteOf(CultRecordKey key) =>
+ new() { MessageId = "wire-delete", SchemaId = SchemaId, RecordKey = key.Value, ShardId = ShardId, ShardEpoch = 1 };
+ }
+
+ private static Rig Build(ICultNetRemoteWriteAuthority? authority, bool primary = true, ICultNetShardWriteForwarder? forwarder = null)
+ {
+ var cache = new CultCache();
+ var schemaId = cache.Registry.GetRequired().SchemaId;
+ var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
+ {
+ Shards =
+ [
+ new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [schemaId],
+ primaryEndpoints: primary ? null : ["cultnet://runtime-owner:3075"])
+ ]
+ });
+ var host = new Server(cache, ServerSecurityOptions.Development());
+ var logger = new CapturingLogger();
+ host.Logger = logger;
+ var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions
+ {
+ RemoteWriteAuthority = authority,
+ ForwardNonPrimaryWrites = forwarder != null,
+ WriteForwarder = forwarder
+ });
+ var peer = (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer));
+ const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;
+ var put = (Func)typeof(CultNetDatabaseServer).GetField("_putHandler", flags)!.GetValue(databaseServer)!;
+ var delete = (Func)typeof(CultNetDatabaseServer).GetField("_deleteHandler", flags)!.GetValue(databaseServer)!;
+ var rig = new Rig
+ {
+ Cache = cache,
+ Database = database,
+ DatabaseServer = databaseServer,
+ Host = host,
+ Peer = peer,
+ Logger = logger,
+ SchemaId = schemaId,
+ Put = message => put(message, peer),
+ Delete = message => delete(message, peer)
+ };
+ rig.Subscription = R3.ObservableExtensions.Subscribe(database.WatchAllChanges(), change => rig.Published.Add(change));
+ return rig;
+ }
+
+ // The server writes its own record through the host's door, which asks no remote authority.
+ private static async Task Seed(Rig rig, CultRecordKey key, string text) => await rig.Database.ApplyPutAsync(rig.PutOf(key, text));
+
+ [Test]
+ public async Task WithNoAuthorityAPeersPutAndDeleteAreRefusedAndChangeNothing()
+ {
+ var rig = Build(authority: null);
+ await Seed(rig, KeyA, "server");
+ var publishedBefore = rig.Published.Count;
+ var logBefore = rig.LogLength;
+ Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"), "the host's own door needs no remote authority");
+
+ Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyA, "peer")), "the peer is answered with a refusal");
+ Assert.CatchAsync(async () => await rig.Delete(rig.DeleteOf(KeyA)), "the peer is answered with a refusal");
+ Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyB, "peer")), "a new key is refused too");
+
+ Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"));
+ Assert.That(rig.TextOf(KeyB), Is.Null);
+ Assert.That(rig.Published, Has.Count.EqualTo(publishedBefore), "a refusal publishes no change");
+ Assert.That(rig.LogLength, Is.EqualTo(logBefore), "a refusal appends no mutation log entry");
+ Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw put refused"));
+ Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw delete refused"));
+ Assert.That(rig.Logger.Errors, Is.Empty, "a refusal is not a fault");
+ }
+
+ [Test]
+ public async Task AGrantedPutAndDeleteCommitAndTheAuthoritySawThisPeer()
+ {
+ var authority = new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant());
+ var rig = Build(authority);
+
+ await rig.Put(rig.PutOf(KeyA, "peer"));
+ Assert.That(rig.TextOf(KeyA), Is.EqualTo("peer"));
+ Assert.That(rig.Published, Has.Count.EqualTo(1));
+ Assert.That(rig.LogLength, Is.EqualTo(1));
+
+ await rig.Delete(rig.DeleteOf(KeyA));
+ Assert.That(rig.TextOf(KeyA), Is.Null);
+
+ Assert.That(authority.Requests, Has.Count.EqualTo(2), "asked once per received write");
+ Assert.That(authority.Requests.Select(request => request.Peer), Has.All.SameAs(rig.Peer));
+ Assert.That(rig.Logger.Errors, Is.Empty);
+ }
+
+ [Test]
+ public async Task TheRequestCarriesTheResolvedWriteAndScopedGrantsRefuseTheirNeighbours()
+ {
+ // The put names a foreign alias of the schema; the payload routes it to the canonical descriptor.
+ var recorded = new RecordingAuthority(request =>
+ request.Operation == CultNetRemoteWriteOperation.Put && request.RecordKey.Equals(KeyA)
+ ? CultNetRemoteWriteDecision.Grant()
+ : CultNetRemoteWriteDecision.Refuse("scoped"));
+ var rig = Build(recorded);
+ await Seed(rig, KeyA, "server");
+ var aliased = rig.PutOf(KeyA, "aliased");
+ aliased.Document.SchemaId = "runtime.generated.network-note.ui.42";
+
+ await rig.Put(aliased);
+
+ var request = recorded.Requests.Single();
+ Assert.That(request.SchemaId, Is.EqualTo(rig.SchemaId), "the authority sees the canonical id, not the alias as sent");
+ Assert.That(request.Operation, Is.EqualTo(CultNetRemoteWriteOperation.Put));
+ Assert.That(request.RecordKey, Is.EqualTo(KeyA));
+ Assert.That(request.ShardId, Is.EqualTo(ShardId));
+ Assert.That(request.ShardEpoch, Is.EqualTo(1));
+ Assert.That(rig.TextOf(KeyA), Is.EqualTo("aliased"));
+
+ Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyB, "other-key")));
+ Assert.CatchAsync(async () => await rig.Delete(rig.DeleteOf(KeyA)));
+ Assert.That(rig.TextOf(KeyB), Is.Null, "a grant scoped to key a refuses key b");
+ Assert.That(rig.TextOf(KeyA), Is.EqualTo("aliased"), "a grant scoped to Put refuses Delete");
+ Assert.That(recorded.Requests.Last().Operation, Is.EqualTo(CultNetRemoteWriteOperation.Delete));
+ Assert.That(recorded.Requests.Last().SchemaId, Is.EqualTo(rig.SchemaId));
+ }
+
+ [Test]
+ public async Task ARefusedWriteIsNeverForwardedAndAGrantedOneIs()
+ {
+ var forwarder = new CountingForwarder();
+ var refusing = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no")), primary: false, forwarder);
+
+ Assert.CatchAsync(async () => await refusing.Put(refusing.PutOf(KeyA, "peer")));
+ Assert.CatchAsync(async () => await refusing.Delete(refusing.DeleteOf(KeyA)));
+ Assert.That(forwarder.Puts, Is.EqualTo(0), "a refused put is not forwarded");
+ Assert.That(forwarder.Deletes, Is.EqualTo(0), "a refused delete is not forwarded");
+
+ var unconfigured = Build(authority: null, primary: false, forwarder);
+ Assert.CatchAsync(async () => await unconfigured.Put(unconfigured.PutOf(KeyA, "peer")));
+ Assert.That(forwarder.Puts, Is.EqualTo(0), "no authority forwards nothing");
+
+ var granting = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), primary: false, forwarder);
+ await granting.Put(granting.PutOf(KeyA, "peer"));
+ await granting.Delete(granting.DeleteOf(KeyA));
+ Assert.That(forwarder.Puts, Is.EqualTo(1), "a granted write on a non-primary shard is forwarded as before");
+ Assert.That(forwarder.Deletes, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void RefusalPrecedesTheUnownedSchemaCheck()
+ {
+ var unowned = new MeshQuickstartNote { NoteId = "u", Body = "unowned" };
+ var rig = Build(authority: null);
+ var put = rig.Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(Unowned), unowned);
+ put.ShardId = ShardId;
+ put.ShardEpoch = 1;
+
+ Assert.CatchAsync(async () => await rig.Put(put));
+
+ Assert.That(rig.Logger.Warnings, Has.Some.Contains("authority"), "an ungranted put of an unowned schema is answered write_not_authorized");
+ Assert.That(rig.Logger.Warnings, Has.None.Contains("No shard owns"));
+
+ var granting = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()));
+ var grantedPut = granting.Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(Unowned), unowned);
+ grantedPut.ShardId = ShardId;
+ grantedPut.ShardEpoch = 1;
+ Assert.CatchAsync(async () => await granting.Put(grantedPut));
+ Assert.That(granting.Logger.Warnings, Has.Some.Contains("No shard owns"), "a granted put still meets the unowned-schema refusal");
+ }
+
+ [Test]
+ public async Task AnAuthorityThatThrowsRefusesAndIsLoggedAsAnError()
+ {
+ var rig = Build(new RecordingAuthority(_ => throw new InvalidOperationException("authority exploded")));
+ await Seed(rig, KeyA, "server");
+
+ Assert.CatchAsync(async () => await rig.Put(rig.PutOf(KeyA, "peer")), "the peer is answered with a refusal");
+ Assert.CatchAsync(async () => await rig.Delete(rig.DeleteOf(KeyA)));
+
+ Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"), "fail closed");
+ Assert.That(rig.Logger.Errors, Has.Some.Contains("authority exploded"));
+ }
+
+ [Test]
+ public void TheRefusalNamesTheKeyAndTheCode()
+ {
+ var request = new CultNetRemoteWriteRequest(null!, CultNetRemoteWriteOperation.Delete, "s", KeyA, null, null);
+ var message = CultNetErrorMessage.ForRemoteWriteRefused(request, "why not");
+
+ Assert.That(message.Code, Is.EqualTo("write_not_authorized"));
+ Assert.That(message.Error, Is.EqualTo("write_not_authorized: why not"));
+ Assert.That(message.Details!.Field, Is.EqualTo("recordKey"));
+ Assert.That(message.Details.Value, Is.EqualTo(KeyA.Value));
+ Assert.That(message.RoutingHint, Is.Null);
+ Assert.That(CultNetErrorMessage.ForRemoteWriteRefused(request, null).Error, Is.EqualTo("write_not_authorized"));
+ }
+
+ private sealed class RecordingAuthority : ICultNetRemoteWriteAuthority
+ {
+ private readonly Func _decide;
+ public List Requests { get; } = new();
+ public RecordingAuthority(Func decide) => _decide = decide;
+
+ public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request)
+ {
+ Requests.Add(request);
+ return _decide(request);
+ }
+ }
+
+ private sealed class CountingForwarder : ICultNetShardWriteForwarder
+ {
+ public int Puts { get; private set; }
+ public int Deletes { get; private set; }
+ public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { Puts++; return Task.CompletedTask; }
+ public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { Deletes++; return Task.CompletedTask; }
+ }
+
+ private sealed class CapturingLogger : GameCult.Logging.ILogger
+ {
+ public List Errors { get; } = new();
+ public List Warnings { get; } = new();
+ public void LogInfo(string message) { }
+ public void LogWarning(string message) => Warnings.Add(message);
+ public void LogError(string message) => Errors.Add(message);
+ public void LogDebug(string message) { }
+ }
+ }
+}
diff --git a/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs b/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs
index a4796e7d..726dfea4 100644
--- a/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs
+++ b/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs
@@ -56,7 +56,7 @@ namespace GameCult.Networking.Tests
using var server = new Server(cache, ServerSecurityOptions.Development());
var logger = new Logger();
server.Logger = logger;
- using var databaseServer = new CultNetDatabaseServer(server, database);
+ using var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = new TestGrantsEverything() });
var handler = (Func)typeof(CultNetDatabaseServer)
.GetField("_putHandler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(databaseServer)!;