moderation(L4): enforce bans/timeouts at auth, ingest, and connection seams#1594
Merged
Merged
Conversation
a23a356 to
321fa04
Compare
…tion seams Phase-1 community moderation enforcement lane (L4). Consumes L1's `Db::moderation_restriction_state` seam at three points: - Auth seam (handlers/auth.rs): reject AUTH from a banned member with `OK false "blocked: you are banned from this community"` and immediately close the socket (decision 4). The reason frame is routed over the control channel and the send loop drains it ahead of the Close, so the client learns why before the socket drops. Includes a NIP-OA owner cascade — an agent whose crypto-proven owner is banned is refused too. A DB error fails closed but denies with `error: internal ...` rather than falsely claiming a ban. - Ingest write-block (handlers/ingest.rs): a timed-out member's EVENTs are refused with `restricted: you are timed out until <ts>` until the timeout expires. A ban is also re-checked here: an already-authenticated connection never re-auths, so if the live-disconnect fan-out is missed (fire-and-forget publish, broadcast lag, subscriber reconnect window), this write-path gate is the durable backstop that stops a banned member writing indefinitely. Moderation-command and relay-admin kinds are exempt so the tools that lift a restriction are never disarmed. Fails closed. The gate checks the authoring pubkey only; the NIP-OA cascade is structural at the auth seam for bans, and timeout's owner cascade is a documented Phase-1 asymmetry (IngestAuth carries no auth tag). - Live disconnect (state.rs): `ConnectionManager::disconnect_pubkey` closes every live socket for a pubkey *within the banning community*, delivering the close reason over the control channel. The community filter is the tenant fence: one pod holds sockets for many communities, so a ban in A must never kill the same member's session in B. Cross-pod fan-out (buzz-pubsub/src/conn_control.rs): a new Redis pub/sub channel carrying `ConnControl::DisconnectPubkey`, parallel to cache_invalidation rather than folded into it — a disconnect is an imperative, non-idempotent action, not a pure cache-key drop. main.rs spawns the subscriber and a consumer that applies inbound commands via disconnect_pubkey, passing the scoped community so the fence holds cross-pod. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
321fa04 to
44eb7d8
Compare
…pState Adds `AppState::disconnect_pubkey_clusterwide`, the single entry point for live ban enforcement: it closes this pod's sockets for the banned pubkey (fenced to the community) and spawns the cross-pod `publish_conn_control` fan-out so every other pod's subscriber closes its sockets too. Closes the wire-up gap Quinn found: #1594 shipped both halves as separate primitives (`conn_manager.disconnect_pubkey` + `PubSubManager:: publish_conn_control`) but nothing called the publish half, so a live ban was pod-local-only — violating decision 4's "immediately, everywhere, including live sessions". Rather than have the ban handler (L6) remember to call both, pairing them on AppState (mirroring `spawn_cache_invalidation`) makes "do half the job" unrepresentable: callers get cluster-wide enforcement from one call and can't silently drop the fan-out. The banning pod re-receives its own publish and no-ops (local sockets already closed) — intentional, documented, no origin-suppression. The return count is pod-local only; remote closes are async and unreported. Fire-and-forget publish with the DB ban row as the durable backstop. No dedicated unit test: pure composition of two already-tested primitives (the fenced pod-local disconnect has three tests incl. the tenant-fence case; publish + serde are covered in conn_control.rs). A full AppState + live-Redis harness for a 15-line pairing is disproportionate. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
0bff742
into
eva/community-moderation
23 of 24 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
L4 — community moderation enforcement seam (Phase 1)
Enforces bans and timeouts at the three points where a restricted member can act. Consumes L1's
Db::moderation_restriction_stateseam (PR #1592).What's enforced
Auth seam (
handlers/auth.rs) — a banned member's AUTH is rejected withOK false "blocked: you are banned from this community"and the connection is dropped. Includes a NIP-OA owner cascade: a crypto-proven owner whose owner-pubkey is banned is refused too. Fails closed on DB error.Ingest write-block (
handlers/ingest.rs) — a timed-out member's EVENTs are refused withrestricted: you are timed out until <ts>until expiry. Moderation-command and relay-admin kinds are exempt so the tools that lift a timeout are never disarmed. Bans never reach here (dropped at auth), so this gate is timeout-only. Fails closed.Live disconnect (
state.rs) —ConnectionManager::disconnect_pubkeycloses every live socket for a pubkey, delivering the close reason over the control channel (drained ahead of the biased cancel insend_loop) so the client learns why before the socket drops.Cross-pod fan-out (
buzz-pubsub/src/conn_control.rs) — a new Redis pub/sub channel carryingConnControl::DisconnectPubkey, parallel tocache_invalidationrather than folded into it: a disconnect is an imperative, non-idempotent action, not a pure cache-key drop.main.rsspawns the subscriber + a consumer that applies inbound commands viadisconnect_pubkey. The DB ban row is the durable backstop — even a dropped command still refuses the banned member's next auth attempt.Lane boundaries
publish_conn_control+ localdisconnect_pubkeyon ban creation is L6 (Quinn).matched_principalaudit at ban-creation is L1/L6.Validation
Validated against the real L1 seam (overlay of
max/moderation-l1DB files, then restored):cargo check -p buzz-pubsub -p buzz-relay -p buzz-db✅conn_controltests, 2disconnect_pubkeytests, 88ingesttests ✅cargo fmt --check✅ ·git diff --check✅ · pre-commit hooks ✅This PR depends on L1 (#1592) for
moderation_restriction_state. Baseeva/community-moderationdoes not yet contain that seam, so CI will not compile until L1 merges intoeva/community-moderationfirst; I'll rebase then. Per Eva's arbitration: no stacking, base =eva/community-moderation.