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
+14
View File
@@ -114,6 +114,20 @@ impl Database {
.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
/// unchanged `seed_fingerprint`). Peppered.
#[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
//! hook, evaluated by the pure engine in [`policy`].
pub mod nauthz;
mod policy;
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 listener = tokio::net::TcpListener::bind(&addr).await?;