diff --git a/api/src/lib.rs b/api/src/lib.rs index aa2ec86..1615972 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -317,33 +317,54 @@ impl ChanModeCap { // A services-operator privilege. Coarse and typed (not a string namespace), so // the compiler checks every use and there is one gating mechanism, not two. +// A services-operator privilege, ordered as tiers: a higher one implies the lower +// ones (see `Privs::has`), so three named tiers — operator, administrator, root — +// fall out of single privileges. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Priv { // See other users' hidden info (INFO fields, LIST filters). Auspex, + // Front-line moderation: network bans (AKILL/x-lines), KICK, KILL, SESSION, + // IGNORE, MODE, SPAMFILTER, NOTIFY, stats — the day-to-day operator tools. + Oper, // Suspend / unsuspend accounts and channels. Suspend, - // Modify or drop other users' accounts and channels (SASET, DROP, GETEMAIL). + // Persistent data + broadcast: modify/drop accounts+channels (SASET, DROP, + // GETEMAIL, NOEXPIRE), FORBID, GLOBAL, DEFCON, SVS, JUPE, and the BotServ / + // MemoServ / InfoServ staff tools. Admin, + // Control the services daemon and the oper roster: OPER (grant/revoke opers), + // SHUTDOWN / RESTART, REHASH, OperServ SET, DebugServ. + Root, } impl Priv { - /// Every privilege, for iteration and validation. - pub const ALL: [Priv; 3] = [Priv::Auspex, Priv::Suspend, Priv::Admin]; + /// Every privilege, low tier to high, for iteration and validation. + pub const ALL: [Priv; 5] = [Priv::Auspex, Priv::Oper, Priv::Suspend, Priv::Admin, Priv::Root]; /// This privilege's canonical name — the `[[oper]]` config token. Exhaustive, /// so adding a variant forces adding its name (no silent gap). pub fn name(self) -> &'static str { match self { Priv::Auspex => "auspex", + Priv::Oper => "oper", Priv::Suspend => "suspend", Priv::Admin => "admin", + Priv::Root => "root", } } - /// Parse a privilege name (case-insensitive); `None` if unrecognised. + /// Parse a privilege name (case-insensitive), accepting the tier aliases + /// `operator`/`administrator`; `None` if unrecognised. pub fn from_name(s: &str) -> Option { - Self::ALL.into_iter().find(|p| s.eq_ignore_ascii_case(p.name())) + match s.to_ascii_lowercase().as_str() { + "auspex" => Some(Priv::Auspex), + "oper" | "operator" => Some(Priv::Oper), + "suspend" => Some(Priv::Suspend), + "admin" | "administrator" => Some(Priv::Admin), + "root" => Some(Priv::Root), + _ => None, + } } /// The recognised names as a comma list, for "valid: …" error messages. @@ -358,13 +379,45 @@ impl Priv { pub struct Privs(u8); impl Privs { + fn bit(p: Priv) -> u8 { + 1 << p as u8 + } // Grant a privilege (builder style: `Privs::default().with(Priv::Admin)`). pub fn with(self, p: Priv) -> Self { - Privs(self.0 | (1 << p as u8)) + Privs(self.0 | Self::bit(p)) } - // Whether this set holds `p`. + // Whether this set can exercise `p`, honouring the tier chain: a higher tier + // implies the lower ones — Root ⊃ Admin ⊃ Oper ⊃ Auspex — and Admin also bundles + // Suspend. So an `admin` gates through an `oper`-level command and can view, but + // a stand-alone `suspend` grant is only that (it doesn't imply Auspex). pub fn has(self, p: Priv) -> bool { - self.0 & (1 << p as u8) != 0 + let b = Self::bit; + let mask = match p { + Priv::Auspex => b(Priv::Auspex) | b(Priv::Oper) | b(Priv::Admin) | b(Priv::Root), + Priv::Oper => b(Priv::Oper) | b(Priv::Admin) | b(Priv::Root), + Priv::Suspend => b(Priv::Suspend) | b(Priv::Admin) | b(Priv::Root), + Priv::Admin => b(Priv::Admin) | b(Priv::Root), + Priv::Root => b(Priv::Root), + }; + self.0 & mask != 0 + } + // Whether `p` was granted literally (no tier implication) — for display, not + // gating. + pub fn contains(self, p: Priv) -> bool { + self.0 & Self::bit(p) != 0 + } + // The named tier this set belongs to, by its highest granted privilege. + pub fn tier(self) -> &'static str { + if self.contains(Priv::Root) { + "Services Root" + } else if self.contains(Priv::Admin) { + "Services Administrator" + } else if self.any() { + // oper, or an auspex/suspend-only grant — a (limited) operator. + "Services Operator" + } else { + "not an operator" + } } // Whether this is a services operator at all (holds any privilege). pub fn any(self) -> bool { @@ -397,9 +450,9 @@ impl Privs { Self::parse_names(names).0 } - // The privilege names held, for display. + // The privilege names granted literally (not implied), for display. pub fn names(self) -> Vec<&'static str> { - Priv::ALL.into_iter().filter(|p| self.has(*p)).map(Priv::name).collect() + Priv::ALL.into_iter().filter(|p| self.contains(*p)).map(Priv::name).collect() } } @@ -2533,25 +2586,38 @@ mod tests { } #[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()); + fn privs_are_tiered_and_imply_the_lower_ones() { + let admin = Privs::default().with(Priv::Admin); + // Admin is a tier: it can exercise the lower privileges too. + assert!(admin.has(Priv::Admin) && admin.has(Priv::Suspend) && admin.has(Priv::Oper) && admin.has(Priv::Auspex)); + // but not the tier above it, + assert!(!admin.has(Priv::Root)); + // and `contains` reports only what was granted literally (for display). + assert!(admin.contains(Priv::Admin) && !admin.contains(Priv::Suspend)); + assert_eq!(admin.tier(), "Services Administrator"); + + // An operator can moderate but not administrate; root can do everything. + let oper = Privs::default().with(Priv::Oper); + assert!(oper.has(Priv::Oper) && oper.has(Priv::Auspex) && !oper.has(Priv::Suspend) && !oper.has(Priv::Admin)); + assert_eq!(oper.tier(), "Services Operator"); + let root = Privs::default().with(Priv::Root); + assert!(root.has(Priv::Root) && root.has(Priv::Admin) && root.has(Priv::Oper) && root.has(Priv::Auspex)); + assert_eq!(root.tier(), "Services Root"); + assert!(!Privs::default().any(), "empty set is not an oper"); } #[test] fn parse_names_reports_typos_instead_of_dropping_them() { // Known names are parsed (case-insensitive); unknown ones are collected so - // a config typo is surfaced, not silently ignored. + // a config typo is surfaced, not silently ignored. "root" is a real priv now. let (privs, unknown) = Privs::parse_names(&["Admin", "auspex", "admn", "root"]); - assert!(privs.has(Priv::Admin) && privs.has(Priv::Auspex)); - assert!(!privs.has(Priv::Suspend)); - assert_eq!(unknown, vec!["admn".to_string(), "root".to_string()]); + assert!(privs.contains(Priv::Admin) && privs.contains(Priv::Auspex) && privs.contains(Priv::Root)); + assert_eq!(unknown, vec!["admn".to_string()]); assert_eq!(Priv::from_name("SUSPEND"), Some(Priv::Suspend)); + assert_eq!(Priv::from_name("administrator"), Some(Priv::Admin)); // tier alias assert_eq!(Priv::from_name("nope"), None); - assert_eq!(Priv::valid_names(), "auspex, suspend, admin"); + assert_eq!(Priv::valid_names(), "auspex, oper, suspend, admin, root"); } #[test] diff --git a/config.example.toml b/config.example.toml index d73bf7b..5164586 100644 --- a/config.example.toml +++ b/config.example.toml @@ -42,12 +42,18 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # [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). +# Services operators, in three tiers of increasing power: +# operator — day-to-day moderation: view hidden info, network bans, kick, +# kill, session limits, ignores, mode, spam filters, log search. +# administrator — the above plus account/channel data: suspend, drop, set flags, +# forbid, global notices, defcon, memo/info/bot administration. +# root — the above plus daemon control: add/remove opers, set, rehash, +# restart, shutdown, the activity feed. +# Name a tier with `type`, or list individual privileges with `privs` (auspex, +# oper, suspend, admin, root). Omit the whole block for a network with no opers. # [[oper]] # account = "yournick" -# privs = ["auspex", "suspend", "admin"] +# type = "root" # Staff audit feed: notable service actions (registrations, drops, vhosts, # suspensions, akicks, access and bot changes, oper host config) are announced diff --git a/modules/operserv/src/ignore.rs b/modules/operserv/src/ignore.rs index b8b9cba..902aca5 100644 --- a/modules/operserv/src/ignore.rs +++ b/modules/operserv/src/ignore.rs @@ -5,8 +5,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; // drop commands from a matching user. A mask with an '@' matches nick!*@host // (ident isn't tracked); a bare mask matches the nick. Admin-only, node-local. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — IGNORE needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — IGNORE needs the \x02operator\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/operserv/src/info.rs b/modules/operserv/src/info.rs index 8d78416..22bc021 100644 --- a/modules/operserv/src/info.rs +++ b/modules/operserv/src/info.rs @@ -4,8 +4,8 @@ use echo_api::{Priv, Sender, ServiceCtx, Store}; // note to an account or channel (a `#name` is a channel, else an account). The // note is shown to operators in that service's INFO. Admin-only. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — INFO needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — INFO needs the \x02operator\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/operserv/src/kick.rs b/modules/operserv/src/kick.rs index 51b187e..9bc4453 100644 --- a/modules/operserv/src/kick.rs +++ b/modules/operserv/src/kick.rs @@ -3,8 +3,8 @@ use echo_api::{NetView, Priv, Sender, ServiceCtx}; // KICK <#channel> [reason]: remove a user from a channel, sourced from // OperServ so it's clearly a staff action. Admin-only. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — KICK needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — KICK needs the \x02operator\x02 privilege."); return; } let Some(&chan) = args.get(1).filter(|c| c.starts_with('#') || c.starts_with('&')) else { diff --git a/modules/operserv/src/kill.rs b/modules/operserv/src/kill.rs index 6322dac..7965dd8 100644 --- a/modules/operserv/src/kill.rs +++ b/modules/operserv/src/kill.rs @@ -2,8 +2,8 @@ use echo_api::{NetView, Priv, Sender, ServiceCtx}; // KILL [reason]: disconnect a user from the network. Admin-only. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — KILL needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — KILL needs the \x02operator\x02 privilege."); return; } let Some(&target) = args.get(1) else { diff --git a/modules/operserv/src/mode.rs b/modules/operserv/src/mode.rs index f533457..e1bd1f0 100644 --- a/modules/operserv/src/mode.rs +++ b/modules/operserv/src/mode.rs @@ -4,8 +4,8 @@ use echo_api::{NetView, Priv, Sender, ServiceCtx, Store}; // (forced, so it applies regardless of the current TS). Admin-only. Status-mode // targets may be given as nicks — they're resolved to uids for the ircd. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — MODE needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — MODE needs the \x02operator\x02 privilege."); return; } let Some(&chan) = args.get(1).filter(|c| c.starts_with('#') || c.starts_with('&')) else { diff --git a/modules/operserv/src/notify.rs b/modules/operserv/src/notify.rs index e1edf3c..1925567 100644 --- a/modules/operserv/src/notify.rs +++ b/modules/operserv/src/notify.rs @@ -9,8 +9,8 @@ const ALL_FLAGS: &str = "cdjkmnoptusS"; // An oper watch list: users matching a mask have their flagged events announced // to the staff feed. Admin-only, like the rest of the ban family. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — NOTIFY needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — NOTIFY needs the \x02operator\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/operserv/src/oper.rs b/modules/operserv/src/oper.rs index 99eef84..b5be2ad 100644 --- a/modules/operserv/src/oper.rs +++ b/modules/operserv/src/oper.rs @@ -1,20 +1,21 @@ -use echo_api::{parse_duration, Priv, Sender, ServiceCtx, Store}; +use echo_api::{parse_duration, Priv, Privs, Sender, ServiceCtx, Store}; use std::time::{SystemTime, UNIX_EPOCH}; // OPER ADD [+duration] | OPER DEL | OPER LIST: // manage runtime services operators (merged with the declarative config opers). -// The privileges are auspex, suspend, admin; a +duration makes the grant expire. -// Admin-only — only an admin grants oper. +// Privileges (low to high): auspex, oper, suspend, admin, root — or name a tier +// directly (operator/administrator/root), which grants the matching privilege. +// Root-only — granting operators is the top-tier power. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — OPER needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Root) { + ctx.notice(me, from.uid, "Access denied — OPER needs the \x02root\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { Some("ADD") | Some("SET") => add(me, from, args.get(2).copied(), args.get(3).copied(), args.get(4).copied(), ctx, db), Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db), Some("LIST") | Some("VIEW") => list(me, from, ctx, db), - _ => ctx.notice(me, from.uid, "Syntax: OPER ADD [+duration] | OPER DEL | OPER LIST — privs: auspex, suspend, admin"), + _ => ctx.notice(me, from.uid, "Syntax: OPER ADD [+duration] | OPER DEL | OPER LIST — tiers: operator, administrator, root; privs: auspex, oper, suspend, admin, root"), } } @@ -76,7 +77,8 @@ fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { } for (account, privs, expires) in &opers { let window = if expires.is_some() { " (temporary)" } else { "" }; - ctx.notice(me, from.uid, format!(" \x02{account}\x02 — {}{window}", privs.join(", "))); + let tier = Privs::from_names(privs).tier(); + ctx.notice(me, from.uid, format!(" \x02{account}\x02 [{tier}] — {}{window}", privs.join(", "))); } ctx.notice(me, from.uid, format!("End of runtime operator list ({} shown).", opers.len())); } diff --git a/modules/operserv/src/redact.rs b/modules/operserv/src/redact.rs index 1db02de..9969bf4 100644 --- a/modules/operserv/src/redact.rs +++ b/modules/operserv/src/redact.rs @@ -1,10 +1,14 @@ -use echo_api::{Sender, ServiceCtx}; +use echo_api::{Priv, Sender, ServiceCtx}; // REDACT <#channel|nick> [reason]: delete a message by its IRCv3 msgid. // The msgid comes from the reporting oper's client (which saw the tagged message); // echo relays it as a server-trusted redaction, so no ircd oper privilege is needed // (draft/message-redaction). One-shot — nothing is stored. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) { + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — REDACT needs the \x02operator\x02 privilege."); + return; + } let (Some(&target), Some(&msgid)) = (args.get(1), args.get(2)) else { ctx.notice(me, from.uid, "Syntax: REDACT <#channel|nick> [reason]"); return; diff --git a/modules/operserv/src/rehash.rs b/modules/operserv/src/rehash.rs index c19d36c..2895acc 100644 --- a/modules/operserv/src/rehash.rs +++ b/modules/operserv/src/rehash.rs @@ -7,8 +7,8 @@ use echo_api::{Priv, Sender, ServiceCtx}; // endpoint still need a RESTART. The engine does the re-read off the command // path and notices the outcome (or a parse error, keeping the old config). pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — REHASH needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Root) { + ctx.notice(me, from.uid, "Access denied — REHASH needs the \x02root\x02 privilege."); return; } ctx.rehash(me, from.uid); diff --git a/modules/operserv/src/session.rs b/modules/operserv/src/session.rs index 697710c..eda37c7 100644 --- a/modules/operserv/src/session.rs +++ b/modules/operserv/src/session.rs @@ -4,8 +4,8 @@ use echo_api::{NetView, Priv, Sender, ServiceCtx, Store}; // EXCEPTION ADD [reason] | DEL | LIST: manage the // per-IP-mask allowances that override the default limit. All admin-only. pub fn handle_session(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — SESSION needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — SESSION needs the \x02operator\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { @@ -33,7 +33,7 @@ pub fn handle_session(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceC } pub fn handle_exception(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { + if !from.privs.has(Priv::Oper) { ctx.notice(me, from.uid, "Access denied — EXCEPTION needs the \x02admin\x02 privilege."); return; } diff --git a/modules/operserv/src/set.rs b/modules/operserv/src/set.rs index f37680c..11a460c 100644 --- a/modules/operserv/src/set.rs +++ b/modules/operserv/src/set.rs @@ -4,8 +4,8 @@ use echo_api::{Priv, Sender, ServiceCtx, Store}; // While on, no new registrations or changes are accepted; existing data and // gossip replication keep working. Admin-only. No argument reports the state. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — SET is for services operators."); + if !from.privs.has(Priv::Root) { + ctx.notice(me, from.uid, "Access denied — SET needs the \x02root\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/operserv/src/shutdown.rs b/modules/operserv/src/shutdown.rs index 4891c0f..32da2dd 100644 --- a/modules/operserv/src/shutdown.rs +++ b/modules/operserv/src/shutdown.rs @@ -6,8 +6,8 @@ use echo_api::{Priv, Sender, ServiceCtx}; // first so users know why services vanished. pub fn handle(me: &str, from: &Sender, restart: bool, args: &[&str], ctx: &mut ServiceCtx) { let cmd = if restart { "RESTART" } else { "SHUTDOWN" }; - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, format!("Access denied — {cmd} needs the \x02admin\x02 privilege.")); + if !from.privs.has(Priv::Root) { + ctx.notice(me, from.uid, format!("Access denied — {cmd} needs the \x02root\x02 privilege.")); return; } let reason = if args.len() > 1 { args[1..].join(" ") } else { format!("Services {}", if restart { "restarting" } else { "shutting down" }) }; diff --git a/modules/operserv/src/spamfilter.rs b/modules/operserv/src/spamfilter.rs index 64e2996..f8a4a71 100644 --- a/modules/operserv/src/spamfilter.rs +++ b/modules/operserv/src/spamfilter.rs @@ -11,8 +11,8 @@ const DEFAULT_FLAGS: &str = "*"; // expression (the ircd's filter engine), e.g. `.*free.*bitcoin.*`. `action` is what // happens on a match: gline / zline / block / silent / kill / shun / warn / none. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !from.privs.has(Priv::Admin) { - ctx.notice(me, from.uid, "Access denied — SPAMFILTER needs the \x02admin\x02 privilege."); + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — SPAMFILTER needs the \x02operator\x02 privilege."); return; } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/modules/operserv/src/stats.rs b/modules/operserv/src/stats.rs index 9606cf5..2bdeff5 100644 --- a/modules/operserv/src/stats.rs +++ b/modules/operserv/src/stats.rs @@ -1,9 +1,13 @@ -use echo_api::{Sender, ServiceCtx, Store, XlineKind}; +use echo_api::{Priv, Sender, ServiceCtx, Store, XlineKind}; // STATS: an at-a-glance summary of the enforcement state OperServ holds — how // many network bans of each kind and how many services ignores are live. // Read-only, so any operator may run it (the module is already oper-gated). pub fn handle(me: &str, from: &Sender, db: &mut dyn Store, ctx: &mut ServiceCtx) { + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, "Access denied — STATS needs the \x02operator\x02 privilege."); + return; + } let akills = db.akills(); let glines = akills.iter().filter(|a| a.kind == XlineKind::Gline).count(); let qlines = akills.iter().filter(|a| a.kind == XlineKind::Qline).count(); diff --git a/modules/operserv/src/xline.rs b/modules/operserv/src/xline.rs index 2784b82..3174bf0 100644 --- a/modules/operserv/src/xline.rs +++ b/modules/operserv/src/xline.rs @@ -1,4 +1,4 @@ -use echo_api::{parse_duration, Sender, ServiceCtx, Store, XlineKind}; +use echo_api::{parse_duration, Priv, Sender, ServiceCtx, Store, XlineKind}; use std::time::{SystemTime, UNIX_EPOCH}; // A network-ban command family (AKILL, SQLINE, …): the ircd X-line `kind`, the @@ -13,6 +13,10 @@ pub struct Xline { impl Xline { pub fn handle(&self, me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Oper) { + ctx.notice(me, from.uid, format!("Access denied — {} needs the \x02operator\x02 privilege.", self.name)); + return; + } match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { Some("ADD") => self.add(me, from, &args[2..], ctx, db), Some("DEL") | Some("REMOVE") => self.del(me, from, args.get(2).copied(), ctx, db), diff --git a/modules/statserv/src/lib.rs b/modules/statserv/src/lib.rs index 861ede8..31a0507 100644 --- a/modules/statserv/src/lib.rs +++ b/modules/statserv/src/lib.rs @@ -55,7 +55,7 @@ fn require_channel_admin(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceC ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); return false; }; - if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) { + if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Oper) { ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can see its stats.")); return false; } diff --git a/src/config.rs b/src/config.rs index 8f28b20..66e21b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -173,7 +173,23 @@ pub struct Log { pub struct Oper { pub account: String, #[serde(default)] - pub privs: Vec, // "auspex" | "suspend" | "admin" + pub privs: Vec, // fine-grained: auspex, oper, suspend, admin, root + // A tier shortcut: "operator", "administrator", or "root" — expands to the + // matching privilege (which implies the lower tiers). Combined with `privs`. + #[serde(default, rename = "type")] + pub oper_type: Option, +} + +impl Oper { + // Every privilege-name granted, from both the `type` shortcut and the explicit + // `privs` list. + fn all_priv_names(&self) -> Vec { + let mut names = self.privs.clone(); + if let Some(t) = &self.oper_type { + names.push(t.clone()); + } + names + } } impl Config { @@ -181,7 +197,7 @@ impl Config { pub fn opers(&self) -> std::collections::HashMap { let mut map = std::collections::HashMap::new(); for o in &self.oper { - map.insert(o.account.to_ascii_lowercase(), echo_api::Privs::from_names(&o.privs)); + map.insert(o.account.to_ascii_lowercase(), echo_api::Privs::from_names(&o.all_priv_names())); } map } @@ -192,7 +208,7 @@ impl Config { self.oper .iter() .flat_map(|o| { - let (_, unknown) = echo_api::Privs::parse_names(&o.privs); + let (_, unknown) = echo_api::Privs::parse_names(&o.all_priv_names()); unknown.into_iter().map(move |name| (o.account.clone(), name)) }) .collect() diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 59f12d5..cefc99a 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -673,7 +673,7 @@ // OperServ REHASH is admin-only and hands the reload to the engine (resolved // in the link layer) addressed back to the oper who ran it. #[test] - fn operserv_rehash_is_admin_only() { + fn operserv_rehash_is_root_only() { use echo_operserv::OperServ; let path = std::env::temp_dir().join("echo-opsrehash.jsonl"); let _ = std::fs::remove_file(&path); @@ -690,22 +690,22 @@ ); e.set_sid("42S".into()); let mut opers = std::collections::HashMap::new(); - opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin)); - opers.insert("bob".to_string(), Privs::default().with(echo_api::Priv::Auspex)); + opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Root)); + opers.insert("bob".to_string(), Privs::default().with(echo_api::Priv::Admin)); e.set_opers(opers); e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); - // Admin: hands off the reload, addressed back to the requesting oper. + // Root: hands off the reload, addressed back to the requesting oper. let a = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: "REHASH".into() }); assert!(a.iter().any(|x| matches!(x, NetAction::Rehash { requester, agent } if requester == "000AAAAAB" && agent == "42SAAAAAH")), "{a:?}"); - // Oper without admin: denied, no reload handed off. + // An administrator (not root): denied, no reload handed off. let b = e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAH".into(), text: "REHASH".into() }); - assert!(!b.iter().any(|x| matches!(x, NetAction::Rehash { .. })), "non-admin must not rehash: {b:?}"); - assert!(b.iter().any(|x| matches!(x, NetAction::Notice { text, .. } if text.contains("admin"))), "{b:?}"); + assert!(!b.iter().any(|x| matches!(x, NetAction::Rehash { .. })), "a non-root oper must not rehash: {b:?}"); + assert!(b.iter().any(|x| matches!(x, NetAction::Notice { text, .. } if text.contains("root"))), "{b:?}"); } // REHASH re-reads the file, applies the reloadable settings, and announces the @@ -1901,7 +1901,7 @@ db, ); let mut opers = std::collections::HashMap::new(); - opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin)); + opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Root)); e.set_opers(opers); e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() }); @@ -1941,7 +1941,7 @@ db, ); let mut opers = std::collections::HashMap::new(); - opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin)); + opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Root)); e.set_opers(opers); e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() }); @@ -4019,7 +4019,7 @@ ); e.set_sid("42S".into()); let mut opers = std::collections::HashMap::new(); - opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin).with(echo_api::Priv::Auspex)); + opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Root)); opers.insert("mod".to_string(), Privs::default().with(echo_api::Priv::Suspend)); e.set_opers(opers); let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); @@ -4344,7 +4344,7 @@ ); e.set_sid("42S".into()); let mut opers = std::collections::HashMap::new(); - opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin)); + opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Root)); e.set_opers(opers); let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); let denied = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))); @@ -4389,7 +4389,7 @@ ); e.set_sid("42S".into()); let mut opers = std::collections::HashMap::new(); - opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Admin)); + opers.insert("staff".to_string(), Privs::default().with(echo_api::Priv::Root)); e.set_opers(opers); let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() }); let denied = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))); @@ -5205,8 +5205,9 @@ fn svc_login(e: &mut Engine, acct: &str) { // Grant the account full operator privileges. fn svc_oper(e: &mut Engine, acct: &str) { + // A full (root-tier) operator: `root` implies every lower privilege. let mut opers = std::collections::HashMap::new(); - opers.insert(acct.to_string(), echo_api::Privs::from_names(&["admin", "suspend", "auspex"])); + opers.insert(acct.to_string(), echo_api::Privs::from_names(&["root"])); e.set_opers(opers); }