From f1415bedb2556bd34a14a16db108c3c8d3bf9d52 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 03:42:59 +0000 Subject: [PATCH] engine: services-operator privilege foundation A typed Priv enum (Auspex/Suspend/Admin) and a Copy Privs bitset carried on Sender, instead of Anope's stringly-typed privilege namespace and its two separate gating mechanisms. Opers are declared in TOML ([[oper]] account + privs); the engine loads them and resolves the sender's privileges per command, so a module just checks from.privs.has(Priv::X). First use: NickServ INFO reveals hidden fields to an auspex oper. Unblocks SUSPEND, admin LIST, SASET, GETEMAIL. Priv bitset test + end-to-end auspex engine test. --- api/src/lib.rs | 50 +++++++++++++++++++++++++++++++++++++++++++- config.example.toml | 7 +++++++ nickserv/src/info.rs | 10 +++++---- src/config.rs | 32 ++++++++++++++++++++++++++++ src/engine/mod.rs | 45 ++++++++++++++++++++++++++++++++++++++- src/main.rs | 1 + 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index d41beea..4f710a6 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -115,12 +115,46 @@ pub trait Protocol: Send { // Service vocabulary // --------------------------------------------------------------------------- +// A services-operator privilege. Coarse and typed (not a string namespace like +// Anope's "nickserv/suspend"), so the compiler checks every use and there is one +// gating mechanism, not two. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Priv { + // See other users' hidden info (INFO fields, LIST filters). + Auspex, + // Suspend / unsuspend accounts and channels. + Suspend, + // Modify or drop other users' accounts and channels (SASET, DROP, GETEMAIL). + Admin, +} + +// The set of privileges a sender holds. A plain Copy bitset — empty means "not +// an oper". Built from the [[oper]] config and carried on every Sender. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Privs(u8); + +impl Privs { + // Grant a privilege (builder style: `Privs::default().with(Priv::Admin)`). + pub fn with(self, p: Priv) -> Self { + Privs(self.0 | (1 << p as u8)) + } + // Whether this set holds `p`. + pub fn has(self, p: Priv) -> bool { + self.0 & (1 << p as u8) != 0 + } + // Whether this is a services operator at all (holds any privilege). + pub fn any(self) -> bool { + self.0 != 0 + } +} + // Who sent the command, resolved by the engine (UID + current nick + the -// account they are identified to, if any). +// account they are identified to, if any, and any oper privileges it holds). pub struct Sender<'a> { pub uid: &'a str, pub nick: &'a str, pub account: Option<&'a str>, + pub privs: Privs, } // The intent sink a service writes to. A service never mutates the network or @@ -534,3 +568,17 @@ pub fn human_time(ts: u64) -> String { let year = y + i64::from(month <= 2); format!("{year:04}-{month:02}-{day:02} {hh:02}:{mm:02}:{ss:02} UTC") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn privs_bitset_grants_and_checks() { + let p = Privs::default().with(Priv::Auspex).with(Priv::Admin); + assert!(p.has(Priv::Auspex) && p.has(Priv::Admin)); + assert!(!p.has(Priv::Suspend), "only granted privileges are held"); + assert!(p.any()); + assert!(!Privs::default().any(), "empty set is not an oper"); + } +} diff --git a/config.example.toml b/config.example.toml index 99fd2f0..21de859 100644 --- a/config.example.toml +++ b/config.example.toml @@ -40,3 +40,10 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # ChanServ. Add "example" to also start the example template service. # [modules] # services = ["nickserv", "chanserv"] + +# Services operators: accounts granted privileges. Omit for a network with no +# opers. Privileges: "auspex" (see others' hidden info), "suspend" +# (suspend/unsuspend), "admin" (modify or drop other accounts/channels). +# [[oper]] +# account = "yournick" +# privs = ["auspex", "suspend", "admin"] diff --git a/nickserv/src/info.rs b/nickserv/src/info.rs index 6d49cd8..61d217b 100644 --- a/nickserv/src/info.rs +++ b/nickserv/src/info.rs @@ -1,8 +1,9 @@ -use fedserv_api::{human_time, Store}; +use fedserv_api::{human_time, Priv, Store}; use fedserv_api::{Sender, ServiceCtx}; -// INFO [account]: show an account's registration details. The email is shown -// only to the account's own owner. +// INFO [account]: show an account's registration details. The email and other +// private fields are shown to the account's own owner, or to an oper with the +// auspex privilege. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) { let name = args.get(1).copied().unwrap_or(from.nick); let Some(acct) = db.account(name) else { @@ -11,7 +12,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: }; ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", acct.name)); ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts))); - if from.account == Some(acct.name.as_str()) { + let is_owner = from.account == Some(acct.name.as_str()); + if is_owner || from.privs.has(Priv::Auspex) { match &acct.email { Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")), Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")), diff --git a/src/config.rs b/src/config.rs index 675a051..0655ad7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,38 @@ pub struct Config { // Which service modules to start. Absent = the built-in NickServ + ChanServ. #[serde(default)] pub modules: Modules, + // Services operators: accounts granted privileges. Absent = no opers. + #[serde(default)] + pub oper: Vec, +} + +// One services operator: an account and the privileges it holds. +#[derive(Debug, Deserialize, Clone)] +pub struct Oper { + pub account: String, + #[serde(default)] + pub privs: Vec, // "auspex" | "suspend" | "admin" +} + +impl Config { + // The account -> privileges table (casefolded keys) built from [[oper]]. + pub fn opers(&self) -> std::collections::HashMap { + use fedserv_api::{Priv, Privs}; + let mut map = std::collections::HashMap::new(); + for o in &self.oper { + let mut privs = Privs::default(); + for p in &o.privs { + match p.to_ascii_lowercase().as_str() { + "auspex" => privs = privs.with(Priv::Auspex), + "suspend" => privs = privs.with(Priv::Suspend), + "admin" => privs = privs.with(Priv::Admin), + other => tracing::warn!(privilege = other, account = %o.account, "unknown oper privilege, ignoring"), + } + } + map.insert(o.account.to_ascii_lowercase(), privs); + } + map + } } // The service modules to bring up at burst. Each name maps to a compiled-in diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 804d7dc..0348d53 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -12,6 +12,7 @@ use tokio::sync::mpsc; use crate::proto::{NetAction, NetEvent, RegReply}; use db::{Db, LogEntry, RegError}; use scram::Verifier; +use fedserv_api::Privs; use service::{Sender, Service, ServiceCtx}; use state::Network; @@ -89,6 +90,7 @@ pub struct Engine { chan_service: Option, // uid to source channel modes from (ChanServ) nick_service: Option, // uid of the account service (NickServ), for its notices irc_out: Option>, // services-initiated actions -> the uplink + opers: HashMap, // casefolded account -> privileges (from [[oper]] config) } impl Engine { @@ -104,6 +106,7 @@ impl Engine { chan_service, nick_service, irc_out: None, + opers: HashMap::new(), } } @@ -114,6 +117,16 @@ impl Engine { self.irc_out = Some(tx); } + // Load the services-operator table (casefolded account -> privileges). + pub fn set_opers(&mut self, opers: HashMap) { + self.opers = opers; + } + + // The privileges an account holds, empty if it is not an oper. + fn oper_privs(&self, account: &str) -> Privs { + self.opers.get(&account.to_ascii_lowercase()).copied().unwrap_or_default() + } + fn emit_irc(&self, action: NetAction) { if let Some(tx) = &self.irc_out { let _ = tx.send(action); // unbounded: never blocks; only fails if the link is down @@ -752,8 +765,9 @@ impl Engine { fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec { let nick = self.network.nick_of(from).unwrap_or(from).to_string(); let account = self.network.account_of(from).map(str::to_string); + let privs = account.as_deref().map(|a| self.oper_privs(a)).unwrap_or_default(); let mut ctx = ServiceCtx::default(); - let sender = Sender { uid: from, nick: &nick, account: account.as_deref() }; + let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs }; let Self { services, network, db, .. } = self; for svc in services.iter_mut() { if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) { @@ -1531,6 +1545,35 @@ mod tests { assert!(out.is_empty(), "no enforcement when SECUREOPS is off: {out:?}"); } + // An auspex oper sees another account's hidden INFO (email); a non-oper does not. + #[test] + fn auspex_oper_sees_hidden_info() { + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-auspex.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "password1", Some("alice@example.org".into())).unwrap(); + db.register("operator", "password1", None).unwrap(); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + let mut opers = std::collections::HashMap::new(); + opers.insert("operator".to_string(), Privs::default().with(fedserv_api::Priv::Auspex)); + e.set_opers(opers); + let info_alice = |e: &mut Engine, uid: &str| { + e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "INFO alice".into() }) + }; + let shows_email = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("alice@example.org"))); + + // The auspex oper identifies and sees alice's email. + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "operator".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + assert!(shows_email(&info_alice(&mut e, "000AAAAAB")), "auspex oper sees the email"); + + // An unidentified (non-oper) viewer does not. + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "guest".into(), host: "h".into() }); + assert!(!shows_email(&info_alice(&mut e, "000AAAAAC")), "non-oper sees no email"); + } + // TOPICLOCK reverts an unauthorised topic change; KEEPTOPIC restores the // remembered topic when the channel is recreated. #[test] diff --git a/src/main.rs b/src/main.rs index e1b54c5..3a9c3f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,6 +77,7 @@ async fn main() -> Result<()> { // Channel for services-initiated actions to reach the uplink (drained by the link loop). let (irc_tx, irc_rx) = tokio::sync::mpsc::unbounded_channel(); engine.lock().await.set_irc_out(irc_tx); + engine.lock().await.set_opers(cfg.opers()); if let Some(gossip) = cfg.gossip.clone() { tracing::info!(peers = cfg.peer.len(), "starting gossip");