feat(relay): gRPC event-admission (nauthz) service enforcing the write policy

nostr-rs-relay's [grpc] event_admission_server calls Authorization.EventAdmit
once per inbound event; we answer PERMIT/DENY so the write policy is enforced at
ingest (dynamic, per-event — a static pubkey whitelist can't do inbox/PoW).

- proto/nauthz.proto (matches nostr-rs-relay's contract) compiled by build.rs;
  protoc is vendored (protoc-bin-vendored) so a source build needs no system
  protobuf-compiler. tonic/prost added (reuse the hyper/tower/h2 stack axum
  already pulls in); server-only codegen.
- infra/relay/nauthz: AdmissionService resolves author + recipient (p-tag)
  registration via the DB, runs the pure policy engine, returns the decision;
  served on a loopback socket (spawned in main only when relay enabled + policy
  != open), mirroring the admin plane. Fail-closed on a malformed request.
- db: is_registered_npub(hex) — a no-PII membership check safe to call per event.
- tests/relay_admission.rs: L1 handler tests (is_registered_npub membership +
  outbox/inbox/deny-non-giftwrap/deny-unregistered-recipient through the real
  event_admit handler against live Postgres).
This commit is contained in:
jwinterm
2026-07-02 06:46:59 -04:00
parent a8f1a9e50c
commit 63d2c75194
9 changed files with 658 additions and 5 deletions
Generated
+296 -5
View File
@@ -104,6 +104,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.42" version = "0.4.42"
@@ -116,6 +122,28 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -767,6 +795,12 @@ dependencies = [
"static_assertions", "static_assertions",
] ]
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -1042,13 +1076,38 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"http 0.2.12", "http 0.2.12",
"indexmap", "indexmap 2.14.0",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tracing", "tracing",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.4.2",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1223,7 +1282,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2 0.3.27",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"httparse", "httparse",
@@ -1247,6 +1306,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.15",
"http 1.4.2", "http 1.4.2",
"http-body 1.0.1", "http-body 1.0.1",
"httparse", "httparse",
@@ -1255,6 +1315,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
"tokio", "tokio",
"want",
] ]
[[package]] [[package]]
@@ -1271,6 +1332,19 @@ dependencies = [
"tokio-rustls 0.24.1", "tokio-rustls 0.24.1",
] ]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.10.1",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.5.0" version = "0.5.0"
@@ -1291,12 +1365,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-channel",
"futures-util",
"http 1.4.2", "http 1.4.2",
"http-body 1.0.1", "http-body 1.0.1",
"hyper 1.10.1", "hyper 1.10.1",
"libc",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.4",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1442,6 +1521,16 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -1478,6 +1567,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1719,6 +1817,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -1954,6 +2058,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
]
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@@ -2081,6 +2195,16 @@ dependencies = [
"termtree", "termtree",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.118",
]
[[package]] [[package]]
name = "proc-macro-error" name = "proc-macro-error"
version = "1.0.4" version = "1.0.4"
@@ -2114,6 +2238,122 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn 2.0.118",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]] [[package]]
name = "quanta" name = "quanta"
version = "0.12.6" version = "0.12.6"
@@ -2247,7 +2487,7 @@ dependencies = [
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2 0.3.27",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"hyper 0.14.32", "hyper 0.14.32",
@@ -2707,6 +2947,8 @@ dependencies = [
"k256", "k256",
"mockall", "mockall",
"monero", "monero",
"prost",
"protoc-bin-vendored",
"rand", "rand",
"reqwest", "reqwest",
"rmp-serde", "rmp-serde",
@@ -2720,6 +2962,8 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls 0.25.0", "tokio-rustls 0.25.0",
"tokio-test", "tokio-test",
"tonic",
"tonic-build",
"tower 0.4.13", "tower 0.4.13",
"tower-http", "tower-http",
"tower_governor", "tower_governor",
@@ -2828,7 +3072,7 @@ dependencies = [
"futures-util", "futures-util",
"hashlink", "hashlink",
"hex", "hex",
"indexmap", "indexmap 2.14.0",
"ipnetwork", "ipnetwork",
"log", "log",
"memchr", "memchr",
@@ -3334,6 +3578,50 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tonic"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
dependencies = [
"async-stream",
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2 0.4.15",
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"prost",
"socket2 0.5.10",
"tokio",
"tokio-stream",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.4.13" version = "0.4.13"
@@ -3342,8 +3630,11 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap 1.9.3",
"pin-project", "pin-project",
"pin-project-lite", "pin-project-lite",
"rand",
"slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower-layer", "tower-layer",
@@ -3612,7 +3903,7 @@ version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.14.0",
"serde", "serde",
"serde_json", "serde_json",
"utoipa-gen", "utoipa-gen",
+11
View File
@@ -107,6 +107,17 @@ async-trait = "0.1"
validator = { version = "0.16", features = ["derive"] } validator = { version = "0.16", features = ["derive"] }
bech32 = "0.12.0" bech32 = "0.12.0"
# gRPC (nostr-rs-relay event-admission hook, `nauthz`). Runtime-gated by
# RELAY_ENABLED; reuses the hyper/tower/h2 stack axum already pulls in.
tonic = "0.12"
prost = "0.13"
[build-dependencies]
# Compile proto/nauthz.proto. protoc is vendored so building from source needs
# no system protobuf-compiler.
tonic-build = "0.12"
protoc-bin-vendored = "3"
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"
mockall = "0.12" mockall = "0.12"
+14
View File
@@ -0,0 +1,14 @@
//! Compile the gRPC proto(s). `protoc` is vendored (protoc-bin-vendored) so a
//! source build needs no system protobuf-compiler.
fn main() {
// Point tonic-build/prost at the vendored protoc.
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
std::env::set_var("PROTOC", protoc);
}
println!("cargo:rerun-if-changed=proto/nauthz.proto");
tonic_build::configure()
.build_client(false) // we only serve the admission service, never call it
.compile_protos(&["proto/nauthz.proto"], &["proto"])
.expect("compile proto/nauthz.proto");
}
+50
View File
@@ -0,0 +1,50 @@
// nostr-rs-relay event-admission (nauthz) gRPC contract.
//
// This MUST match the proto nostr-rs-relay ships (its `[grpc]
// event_admission_server` calls `Authorization.EventAdmit` once per inbound
// event). Keep field numbers/types identical or the relay cannot talk to us.
// Upstream: nostr-rs-relay/proto/nauthz.proto.
syntax = "proto3";
package nauthz;
// Authorization for actions against a relay.
service Authorization {
// Determine if an event should be admitted to the relay.
rpc EventAdmit(EventRequest) returns (EventReply);
}
message Event {
bytes id = 1;
bytes pubkey = 2;
fixed64 created_at = 3;
uint64 kind = 4;
string content = 5;
repeated TagEntry tags = 6;
bytes sig = 7;
}
message TagEntry {
repeated string values = 1;
}
// Event data + connection metadata for the admission decision.
message EventRequest {
Event event = 1;
optional string ip_addr = 2;
optional string origin = 3;
optional string user_agent = 4;
optional bytes auth_pubkey = 5;
optional string nip05 = 6;
}
message EventReply {
Decision decision = 1;
optional string message = 2;
}
enum Decision {
DECISION_UNSPECIFIED = 0;
DECISION_PERMIT = 1;
DECISION_DENY = 2;
}
+14
View File
@@ -114,6 +114,20 @@ impl Database {
.await?) .await?)
} }
/// Whether an x-only pubkey (canonical lowercase hex) is linked to any user —
/// the fast membership check behind the relay write-admission policy. No PII
/// returned, so it is safe to call per inbound relay event.
#[instrument(skip(self, nostr_pubkey))]
pub async fn is_registered_npub(&self, nostr_pubkey: &str) -> Result<bool, AppError> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM users WHERE nostr_pubkey = $1)",
)
.bind(nostr_pubkey)
.fetch_one(self.pool())
.await?;
Ok(exists)
}
/// Replace a user's `pubkey_hash` (derivation-scheme rotation, keyed by the /// Replace a user's `pubkey_hash` (derivation-scheme rotation, keyed by the
/// unchanged `seed_fingerprint`). Peppered. /// unchanged `seed_fingerprint`). Peppered.
#[instrument(skip(self, new_pubkey_hash))] #[instrument(skip(self, new_pubkey_hash))]
+1
View File
@@ -11,6 +11,7 @@
//! write policy, enforces admission via `nostr-rs-relay`'s gRPC event-admission //! write policy, enforces admission via `nostr-rs-relay`'s gRPC event-admission
//! hook, evaluated by the pure engine in [`policy`]. //! hook, evaluated by the pure engine in [`policy`].
pub mod nauthz;
mod policy; mod policy;
pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND}; pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND};
+124
View File
@@ -0,0 +1,124 @@
//! gRPC event-admission service — the `nostr-rs-relay` `nauthz` hook.
//!
//! `nostr-rs-relay` (configured with `[grpc] event_admission_server`) calls
//! `Authorization.EventAdmit` once per inbound event, before storing it. We
//! resolve the author + recipient registration against the DB and run the pure
//! [`super::policy`] engine, returning PERMIT/DENY. This is the adapter-specific
//! transport for the write policy; a different relay backend could enforce the
//! same policy over a different mechanism.
//!
//! Runs on a loopback socket (like the admin plane); never exposed publicly.
use std::sync::Arc;
use tonic::{Request, Response, Status};
use crate::infra::db::Database;
use super::{decide, Admit, EventMeta, RelayProvider};
/// Generated from `proto/nauthz.proto` (see `build.rs`).
pub mod proto {
tonic::include_proto!("nauthz");
}
use proto::authorization_server::{Authorization, AuthorizationServer};
use proto::{Decision, EventReply, EventRequest};
/// The admission decider: the configured relay policy + the user DB.
pub struct AdmissionService {
relay: Arc<dyn RelayProvider>,
db: Database,
}
impl AdmissionService {
pub fn new(relay: Arc<dyn RelayProvider>, db: Database) -> Self {
Self { relay, db }
}
}
fn permit() -> EventReply {
EventReply {
decision: Decision::Permit as i32,
message: None,
}
}
fn deny(msg: &str) -> EventReply {
EventReply {
decision: Decision::Deny as i32,
message: Some(msg.to_string()),
}
}
#[tonic::async_trait]
impl Authorization for AdmissionService {
async fn event_admit(
&self,
request: Request<EventRequest>,
) -> Result<Response<EventReply>, Status> {
let req = request.into_inner();
let Some(event) = req.event else {
// Fail closed on a malformed request.
return Ok(Response::new(deny("missing event")));
};
// Canonical lowercase hex (matches how we store/verify npubs).
let author_hex = hex::encode(&event.pubkey);
let id_hex = hex::encode(&event.id);
// Author registration.
let author_registered = self
.db
.is_registered_npub(&author_hex)
.await
.unwrap_or(false);
// Recipient registration: any `p` tag that is a registered npub. Only
// resolved when needed (skipped once the author is already registered).
let mut recipient_registered = false;
if !author_registered {
for tag in &event.tags {
if tag.values.len() >= 2 && tag.values[0] == "p" {
let p = tag.values[1].to_lowercase();
if self.db.is_registered_npub(&p).await.unwrap_or(false) {
recipient_registered = true;
break;
}
}
}
}
let meta = EventMeta {
author_pubkey: &author_hex,
kind: event.kind,
id_hex: &id_hex,
};
let reply = match decide(
&meta,
self.relay.write_policy(),
self.relay.inbound_pow_bits(),
author_registered,
recipient_registered,
) {
Admit::Permit => permit(),
Admit::Deny(msg) => deny(msg),
};
Ok(Response::new(reply))
}
}
/// Serve the admission service on `addr` (a loopback socket). Returns when the
/// server stops or errors.
pub async fn serve(
addr: std::net::SocketAddr,
relay: Arc<dyn RelayProvider>,
db: Database,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let svc = AdmissionService { relay, db };
tonic::transport::Server::builder()
.add_service(AuthorizationServer::new(svc))
.serve(addr)
.await?;
Ok(())
}
+25
View File
@@ -231,6 +231,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
// Nostr relay event-admission (nauthz) gRPC service — only for a non-`open`
// write policy (open needs no admission). Loopback socket, like the admin
// plane; nostr-rs-relay is configured to call it per inbound event.
if let Some(relay) = state.relay.clone() {
use smirk_backend_core::infra::relay::WritePolicy;
if relay.write_policy() != WritePolicy::Open {
let addr: std::net::SocketAddr = state
.config
.messaging
.relay
.admission_bind
.parse()
.map_err(|e| format!("invalid RELAY_ADMISSION_BIND: {e}"))?;
let db = state.db.clone();
tracing::info!("relay event-admission (nauthz) listening on {addr}");
tokio::spawn(async move {
if let Err(e) =
smirk_backend_core::infra::relay::nauthz::serve(addr, relay, db).await
{
tracing::error!(error = %e, "relay admission service exited");
}
});
}
}
let app = build_router(state); let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
+123
View File
@@ -0,0 +1,123 @@
//! L1 integration: the relay event-admission (nauthz) gRPC handler.
//!
//! Drives `AdmissionService::event_admit` directly (no gRPC transport — that is
//! covered by the docker-relay E2E) to prove the handler wiring: p-tag
//! extraction, the DB registration lookup, and the policy decision, end to end
//! against live Postgres. The pure policy branches are covered in unit tests.
mod common;
use smirk_backend_core::infra::relay::nauthz::proto::authorization_server::Authorization;
use smirk_backend_core::infra::relay::nauthz::{proto, AdmissionService};
use smirk_backend_core::infra::relay::{self, RelayProvider};
use smirk_backend_core::models::db::NewUser;
use std::sync::Arc;
use tonic::Request;
/// A 64-char lowercase-hex pubkey/id (valid for hex::decode).
fn hex64(seed: &str) -> String {
let base = format!("{seed}{}", "0".repeat(64));
base[..64].to_string()
}
async fn register_npub(db: &smirk_backend_core::infra::db::Database, npub_hex: &str) {
db.create_user(NewUser {
username: None,
pubkey_hash: Some(format!("pk-{}", uuid::Uuid::new_v4())),
nostr_pubkey: Some(npub_hex.to_string()),
wallet_birthday: None,
seed_fingerprint: None,
xmr_start_height: None,
wow_start_height: None,
})
.await
.unwrap();
}
fn inbox_outbox_relay(base: &smirk_backend_core::config::Config) -> Arc<dyn RelayProvider> {
let mut cfg = base.clone();
cfg.messaging.relay.enabled = true;
cfg.messaging.relay.write_policy = "inbox-outbox".into();
cfg.messaging.relay.advertised_url = "wss://relay.example.org".into();
cfg.messaging.relay.inbound_pow_bits = 0;
relay::from_config(&cfg).unwrap().unwrap()
}
fn event(author_hex: &str, kind: u64, p_tags: &[&str], id_hex: &str) -> proto::EventRequest {
proto::EventRequest {
event: Some(proto::Event {
id: hex::decode(id_hex).unwrap(),
pubkey: hex::decode(author_hex).unwrap(),
created_at: 0,
kind,
content: String::new(),
tags: p_tags
.iter()
.map(|p| proto::TagEntry {
values: vec!["p".to_string(), (*p).to_string()],
})
.collect(),
sig: vec![],
}),
ip_addr: None,
origin: None,
user_agent: None,
auth_pubkey: None,
nip05: None,
}
}
async fn admit(svc: &AdmissionService, req: proto::EventRequest) -> bool {
let reply = svc
.event_admit(Request::new(req))
.await
.unwrap()
.into_inner();
reply.decision == proto::Decision::Permit as i32
}
#[tokio::test]
async fn is_registered_npub_membership() {
let app = require_app!();
let npub = hex64("aaaa");
assert!(!app.state.db.is_registered_npub(&npub).await.unwrap());
register_npub(&app.state.db, &npub).await;
assert!(app.state.db.is_registered_npub(&npub).await.unwrap());
}
#[tokio::test]
async fn admission_inbox_outbox_end_to_end() {
let app = require_app!();
let svc = AdmissionService::new(inbox_outbox_relay(&app.state.config), app.state.db.clone());
let author = hex64("b1"); // registered author (outbox)
let recipient = hex64("b2"); // registered recipient (inbox)
let stranger = hex64("b3"); // unregistered
register_npub(&app.state.db, &author).await;
register_npub(&app.state.db, &recipient).await;
let ff = "ff".repeat(32);
// Outbox: a registered author's own event → PERMIT.
assert!(
admit(&svc, event(&author, 1, &[], &ff)).await,
"registered author outbox"
);
// Inbox: an unregistered author's gift-wrap (1059) to a registered recipient → PERMIT.
assert!(
admit(&svc, event(&stranger, 1059, &[&recipient], &ff)).await,
"gift-wrap to registered recipient"
);
// External non-gift-wrap → DENY.
assert!(
!admit(&svc, event(&stranger, 1, &[&recipient], &ff)).await,
"external non-gift-wrap rejected"
);
// Gift-wrap to an UNREGISTERED recipient → DENY.
assert!(
!admit(&svc, event(&stranger, 1059, &[&hex64("b9")], &ff)).await,
"gift-wrap to unregistered recipient rejected"
);
}