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
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)")),
|
||||
|
|
|
|||
|
|
@ -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