Skip to content

fix(relay): carry bans to agents and close ban gaps on write and HTTP paths - #8010

Merged
wpfleger96 merged 7 commits into
hayt/audio-ban-checkfrom
hayt/ban-policy-coverage
Oct 1, 2026
Merged

wpfleger96 merged 7 commits into
hayt/audio-ban-checkfrom
hayt/ban-policy-coverage

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Stack: #8005 → this PR

An agent's access comes from its owner, but the community ban and timeout checks only looked at the pubkey that signed the event. Several write paths returned before those checks ran, admission could miss a ban that landed mid-AUTH, and removing or losing a member left live sockets open. This PR closes those gaps.

Behavior

  • Owner restrictions carry to agents. restriction_state folds in the restriction row of the agent's owner (users.agent_owner_pubkey) at read time. Nothing is copied onto the agent, so unbanning or un-timing-out the owner restores the agent immediately. Every hot-path caller (AUTH, ingest, audio, HTTP) picks this up.
  • Admission is fenced against a concurrent ban. Root AUTH and audio admission bind the proven pubkey to the socket first, then make the final ban and relay-membership decision (handlers::auth::final_admission_denial), then refuse if a disconnect already cancelled the socket. A ban or removal whose disconnect ran before the bind is visible to those fresh reads; one that runs after it finds and closes the bound socket. Both reads come from the writer, never the bounded-stale replica, so a removal that committed before the bind cannot be undone by a lagging replica. Both fail closed, and the check covers a delegated agent's owner. Each admitted socket records the agent's owner, from the NIP-OA tag or the stored link.
  • Agents are always linked to their owner before admission. Root AUTH and audio both record users.agent_owner_pubkey for a NIP-OA agent before admitting it, and refuse admission (error: internal error recording agent owner) if that write fails, because revoking the owner finds the agent's sockets through that link.
  • Revoking live access covers agents without a database read. AppState::revoke_live_access sends one clusterwide disconnect that closes, on every pod, each root and audio socket in the community whose principal or recorded owner is the target. A secondary users.agent_owner_pubkey sweep covers the one socket the owner match misses: an agent admitted with no owner link that was linked later (for example by an HTTP NIP-OA request). If that sweep's lookup fails, the error is returned; there is no retry task. The kind-9040 ban, the report-action ban (left non-terminal so recovery re-runs it), relay-admin removal (9031), and NIP-43 self-leave all use it and report an incomplete revoke instead of claiming success. Each revokes right after its change commits; the ban no longer waits on its audit insert, whose error is still returned.
  • NIP-43 self-leave works over WebSocket. Kind 28936 is in the ephemeral range, so handle_event used to fan it out and answer OK true without removing membership. It is now routed to ingest; no other ephemeral kind changes.
  • The write gate runs before every kind-specific branch. enforce_write_restriction runs ahead of command routing (DM open, DM add member, DM hide, workflow definitions and triggers, approvals), product feedback, reports, and moderation or relay-admin commands. Ephemeral events and agent observer frames return before ingest in handle_event, so they go through the same gate there.
    • A ban exempts nothing.
    • A timeout admits only reports (kind 1984), unban (9041) and untimeout (9043). A timed-out admin cannot send relay-admin commands (9030–9033) until the timeout ends.
  • HTTP refuses banned principals. enforce_relay_membership runs a ban check after the membership decision, including on open relays: 403 blocked: you are banned from this community for a ban, 503 for a failed lookup. Media upload, media GET/HEAD and Git transport keep that 503 instead of reporting it as "not a relay member": in NIP-FI Enforce mode they send the canonical AuthorizationUnavailable response (text/plain; charset=utf-8, authorization unavailable\n), and in Off mode each keeps its legacy unavailable response. Media storage failures are not reclassified. Git transport checks membership and then runs its own ban check once per request. The NIP-OA owner is resolved from x-auth-tag when present. HTTP checks bans only: a timeout blocks writes, HTTP writes already reach the ingest gate, and reading stays allowed under a timeout.

Unchanged by design: a lost Redis fan-out can leave another pod's socket open until it reconnects, and there is no recurring ban recheck on open sockets.

Path table

Path Category Ban check
Root WebSocket AUTH and audio admission, including NIP-OA owner binding community admission final fenced check after bind; owner link required
POST /events, /query, /count (bridge.rs) community HTTP existing enforce_relay_membership caller
GIF search and POST /gifs/share (gifs.rs), media upload and fetch (media.rs), workflows (workflows.rs) community HTTP existing caller; media keeps 503 on lookup failure
Git transport (git/transport.rs), git settings (git/settings.rs) community HTTP existing caller; transport keeps 503 on lookup failure
GET /moderation/reports, /moderation/audit, /moderation/restricted community HTTP newly covered
POST /api/invites (invite mint) community HTTP newly covered
NIP-43 self-leave (WebSocket and POST /events, both via ingest) roster change revokes live access like admin removal
Invite claim, accept-policy, /api/join-policy* pre-membership invite and policy flows none; ban state is checked when the caller authenticates
/, /info, /.well-known/nostr.json, /health, /_liveness, /_readiness public metadata and health none
/hooks/{id} secret-authenticated webhooks none; no community pubkey
/operator/*, /api/admin/v1, /internal/git/policy operator, admin and internal routes none; separate auth plane
/_mesh/demo/echo demo only (mesh plus mesh_demo_echo) none

Known limits

  • Cross-pod closure depends on the Redis fan-out; the tests prove the pod-local close by principal and recorded owner.
  • Live delivery to existing subscriptions depends on the disconnect. Events are not rechecked one by one.

… paths

An agent's access derives from its owner's, but the ban and timeout checks
only looked at the authoring pubkey, and commands, ephemeral events, observer
frames, moderation reads, and invite mint all bypassed them. Removing a member
also left their sockets open.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Hayt and others added 6 commits September 30, 2026 19:19
…l revoke

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The ban check now folds in the agent owner's status via users, so the
fault-injection schema must carry that table or the ban check errors
before the allowlist and membership steps under test.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ke gaps

Root and audio admission now bind the proven identity before the final
ban/membership read, so a ban whose disconnect lands mid-AUTH is either seen
by the read or cancels the bound socket. Agents whose NIP-OA owner link
cannot be recorded are refused on both sockets, since revoking the owner
finds agents through that link.

A failed owned-agent lookup during revoke now propagates and keeps retrying
in the background instead of being logged and dropped. NIP-43 self-leave
revokes live access like admin removal. Media and Git keep 503 for a failed
restriction lookup instead of reporting it as a 403.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each test drives the production path: a ban committed while AUTH is paused
after its policy reads, root and audio refusal when the agent owner link
cannot be recorded, an audio-only agent closed by its owner's revoke, the
owned-agent lookup retry, NIP-43 self-leave closing root, audio and agent
sockets, and media/Git keeping 503 when only the restriction lookup fails.

The observer-expiry barrier test moves to the Postgres lane because the
observer write gate now reads restriction state before its barrier.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Root and audio sockets record the admitted agent owner, so revoking the
owner closes them clusterwide with no database read; the detached retry
task is gone. The final admission fence reads membership from the writer,
a WebSocket NIP-43 leave now reaches ingest, a direct ban revokes before
its audit insert, and media/Git policy-lookup failures return the NIP-FI
authorization-unavailable body outside Off mode.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each regression drives the production path and fails with its fix removed:
WebSocket self-leave, removal during AUTH behind a stale replica, revocation
of a recorded-owner agent with no agent lookup, Enforce/Off 503 bytes for
media and Git, and a ban whose audit insert fails.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 merged commit d1d4731 into hayt/audio-ban-check Oct 1, 2026
133 of 135 checks passed
@wpfleger96
wpfleger96 deleted the hayt/ban-policy-coverage branch October 1, 2026 15:27

This branch was successfully deployed

No deployments
codex-review — d1d47317 Deployed Oct 1, 2026 by wpfleger96 via Run Codex Security Review #6373
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant