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:
parent
4600ee062c
commit
f1415bedb2
6 changed files with 139 additions and 6 deletions
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -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<String>, // uid to source channel modes from (ChanServ)
|
||||
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
|
||||
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
|
||||
opers: HashMap<String, Privs>, // 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<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) {
|
||||
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<NetAction> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue