mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-21 01:08:41 +02:00
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:
@@ -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))]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user