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.
This commit is contained in:
Jean Chevronnet 2026-07-13 03:42:59 +00:00
parent 4600ee062c
commit f1415bedb2
No known key found for this signature in database
6 changed files with 139 additions and 6 deletions

View file

@ -115,12 +115,46 @@ pub trait Protocol: Send {
// Service vocabulary // 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 // 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 struct Sender<'a> {
pub uid: &'a str, pub uid: &'a str,
pub nick: &'a str, pub nick: &'a str,
pub account: Option<&'a str>, pub account: Option<&'a str>,
pub privs: Privs,
} }
// The intent sink a service writes to. A service never mutates the network or // 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); let year = y + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02} {hh:02}:{mm:02}:{ss:02} UTC") 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");
}
}

View file

@ -40,3 +40,10 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205
# ChanServ. Add "example" to also start the example template service. # ChanServ. Add "example" to also start the example template service.
# [modules] # [modules]
# services = ["nickserv", "chanserv"] # 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"]

View file

@ -1,8 +1,9 @@
use fedserv_api::{human_time, Store}; use fedserv_api::{human_time, Priv, Store};
use fedserv_api::{Sender, ServiceCtx}; use fedserv_api::{Sender, ServiceCtx};
// INFO [account]: show an account's registration details. The email is shown // INFO [account]: show an account's registration details. The email and other
// only to the account's own owner. // 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) { 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 name = args.get(1).copied().unwrap_or(from.nick);
let Some(acct) = db.account(name) else { 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!("Information for \x02{}\x02:", acct.name));
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts))); 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 { match &acct.email {
Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {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)")), Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")),

View file

@ -19,6 +19,38 @@ pub struct Config {
// Which service modules to start. Absent = the built-in NickServ + ChanServ. // Which service modules to start. Absent = the built-in NickServ + ChanServ.
#[serde(default)] #[serde(default)]
pub modules: Modules, pub modules: Modules,
// Services operators: accounts granted privileges. Absent = no opers.
#[serde(default)]
pub oper: Vec<Oper>,
}
// 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<String>, // "auspex" | "suspend" | "admin"
}
impl Config {
// The account -> privileges table (casefolded keys) built from [[oper]].
pub fn opers(&self) -> std::collections::HashMap<String, fedserv_api::Privs> {
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 // The service modules to bring up at burst. Each name maps to a compiled-in

View file

@ -12,6 +12,7 @@ use tokio::sync::mpsc;
use crate::proto::{NetAction, NetEvent, RegReply}; use crate::proto::{NetAction, NetEvent, RegReply};
use db::{Db, LogEntry, RegError}; use db::{Db, LogEntry, RegError};
use scram::Verifier; use scram::Verifier;
use fedserv_api::Privs;
use service::{Sender, Service, ServiceCtx}; use service::{Sender, Service, ServiceCtx};
use state::Network; use state::Network;
@ -89,6 +90,7 @@ pub struct Engine {
chan_service: Option<String>, // uid to source channel modes from (ChanServ) chan_service: Option<String>, // uid to source channel modes from (ChanServ)
nick_service: Option<String>, // uid of the account service (NickServ), for its notices nick_service: Option<String>, // uid of the account service (NickServ), for its notices
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
opers: HashMap<String, Privs>, // casefolded account -> privileges (from [[oper]] config)
} }
impl Engine { impl Engine {
@ -104,6 +106,7 @@ impl Engine {
chan_service, chan_service,
nick_service, nick_service,
irc_out: None, irc_out: None,
opers: HashMap::new(),
} }
} }
@ -114,6 +117,16 @@ impl Engine {
self.irc_out = Some(tx); self.irc_out = Some(tx);
} }
// Load the services-operator table (casefolded account -> privileges).
pub fn set_opers(&mut self, opers: HashMap<String, Privs>) {
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) { fn emit_irc(&self, action: NetAction) {
if let Some(tx) = &self.irc_out { if let Some(tx) = &self.irc_out {
let _ = tx.send(action); // unbounded: never blocks; only fails if the link is down 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<NetAction> { fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
let nick = self.network.nick_of(from).unwrap_or(from).to_string(); let nick = self.network.nick_of(from).unwrap_or(from).to_string();
let account = self.network.account_of(from).map(str::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 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; let Self { services, network, db, .. } = self;
for svc in services.iter_mut() { for svc in services.iter_mut() {
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) { 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:?}"); 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 // TOPICLOCK reverts an unauthorised topic change; KEEPTOPIC restores the
// remembered topic when the channel is recreated. // remembered topic when the channel is recreated.
#[test] #[test]

View file

@ -77,6 +77,7 @@ async fn main() -> Result<()> {
// Channel for services-initiated actions to reach the uplink (drained by the link loop). // Channel for services-initiated actions to reach the uplink (drained by the link loop).
let (irc_tx, irc_rx) = tokio::sync::mpsc::unbounded_channel(); let (irc_tx, irc_rx) = tokio::sync::mpsc::unbounded_channel();
engine.lock().await.set_irc_out(irc_tx); engine.lock().await.set_irc_out(irc_tx);
engine.lock().await.set_opers(cfg.opers());
if let Some(gossip) = cfg.gossip.clone() { if let Some(gossip) = cfg.gossip.clone() {
tracing::info!(peers = cfg.peer.len(), "starting gossip"); tracing::info!(peers = cfg.peer.len(), "starting gossip");