Add GroupServ, with groups that grant channel access

User groups (!name) — a founder plus member accounts, each with group-
access flags (the shared flag primitive: F founder, f manage, i invite,
c chan-access, s set, m memo). REGISTER/DROP/INFO/LIST/ADD/DEL/FLAGS;
event-sourced and federated like accounts.

The flagship interconnection: a channel access entry may name a !group, so
every member inherits that channel access. Resolution is group-aware — a
new db.channel_caps() unions a user's direct access with any group they
belong to, and the engine's join handler auto-ops/voices through it. So
"ChanServ FLAGS #chan !team +o" auto-ops every member of !team, and
removing someone from the group revokes it. Opt-in.
This commit is contained in:
Jean Chevronnet 2026-07-14 04:26:07 +00:00
parent c7a4d5be27
commit 09be99785e
No known key found for this signature in database
10 changed files with 602 additions and 17 deletions

8
Cargo.lock generated
View file

@ -330,6 +330,7 @@ dependencies = [
"fedserv-chanserv", "fedserv-chanserv",
"fedserv-diceserv", "fedserv-diceserv",
"fedserv-example", "fedserv-example",
"fedserv-groupserv",
"fedserv-hostserv", "fedserv-hostserv",
"fedserv-infoserv", "fedserv-infoserv",
"fedserv-inspircd", "fedserv-inspircd",
@ -390,6 +391,13 @@ dependencies = [
"fedserv-api", "fedserv-api",
] ]
[[package]]
name = "fedserv-groupserv"
version = "0.0.1"
dependencies = [
"fedserv-api",
]
[[package]] [[package]]
name = "fedserv-hostserv" name = "fedserv-hostserv"
version = "0.0.1" version = "0.0.1"

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv"] members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv"]
[package] [package]
name = "fedserv" name = "fedserv"
@ -21,6 +21,7 @@ fedserv-operserv = { path = "operserv" }
fedserv-diceserv = { path = "diceserv" } fedserv-diceserv = { path = "diceserv" }
fedserv-infoserv = { path = "infoserv" } fedserv-infoserv = { path = "infoserv" }
fedserv-reportserv = { path = "reportserv" } fedserv-reportserv = { path = "reportserv" }
fedserv-groupserv = { path = "groupserv" }
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -515,17 +515,60 @@ pub fn level_caps(level: &str) -> Caps {
} }
} }
// Apply a `+ov-h`-style delta to a flag string, returning the new sorted, unique // The granular group-membership flags (GroupServ), same primitive, different
// flag string. A bare "ov" (no sign) grants. Returns Err with the first invalid // letters: F founder, f modify the member/flags list, i invite members, s set
// flag letter. // group options, c the group may be granted channel access, m group memos.
pub fn apply_flags(current: &str, delta: &str) -> Result<String, char> { pub const GROUP_FLAGS: &str = "Fficsm";
let mut set: Vec<char> = current.chars().filter(|c| ACCESS_FLAGS.contains(*c)).collect();
impl Caps {
// Combine two capability sets (a direct access entry and a group's), taking
// the union of privileges, the higher rank, and the strongest auto-mode.
pub fn union(self, other: Caps) -> Caps {
let rank_of = |m: Option<&'static str>| match m {
Some("+o") => 3,
Some("+h") => 2,
Some("+v") => 1,
_ => 0,
};
let auto = if rank_of(self.auto) >= rank_of(other.auto) { self.auto } else { other.auto };
Caps {
auto,
op: self.op || other.op,
topic: self.topic || other.topic,
invite: self.invite || other.invite,
access: self.access || other.access,
set: self.set || other.set,
greet: self.greet || other.greet,
rank: self.rank.max(other.rank),
}
}
}
// A user group (GroupServ) and its members, as a read view.
#[derive(Debug, Clone)]
pub struct GroupMemberView {
pub account: String,
pub flags: String,
}
#[derive(Debug, Clone)]
pub struct GroupView {
pub name: String,
pub founder: String,
pub members: Vec<GroupMemberView>,
}
// Apply a `+ov-h`-style delta to a flag string against a `valid` letter set,
// returning the new deduplicated flag string ordered by `valid`. A bare "ov"
// (no sign) grants. Returns Err with the first letter not in `valid`.
pub fn apply_flags(current: &str, delta: &str, valid: &str) -> Result<String, char> {
let mut set: Vec<char> = current.chars().filter(|c| valid.contains(*c)).collect();
let mut adding = true; let mut adding = true;
for c in delta.chars() { for c in delta.chars() {
match c { match c {
'+' => adding = true, '+' => adding = true,
'-' => adding = false, '-' => adding = false,
f if ACCESS_FLAGS.contains(f) => { f if valid.contains(f) => {
set.retain(|&x| x != f); set.retain(|&x| x != f);
if adding { if adding {
set.push(f); set.push(f);
@ -534,8 +577,7 @@ pub fn apply_flags(current: &str, delta: &str) -> Result<String, char> {
other => return Err(other), other => return Err(other),
} }
} }
// A stable, deduplicated order following ACCESS_FLAGS. Ok(valid.chars().filter(|c| set.contains(c)).collect())
Ok(ACCESS_FLAGS.chars().filter(|c| set.contains(c)).collect())
} }
// One auto-kick entry (a hostmask and the reason shown on kick). // One auto-kick entry (a hostmask and the reason shown on kick).
@ -892,6 +934,17 @@ pub trait Store {
fn report_del(&mut self, id: u64) -> bool; fn report_del(&mut self, id: u64) -> bool;
fn reports(&self, open_only: bool) -> Vec<ReportView>; fn reports(&self, open_only: bool) -> Vec<ReportView>;
fn report(&self, id: u64) -> Option<ReportView>; fn report(&self, id: u64) -> Option<ReportView>;
// User groups (GroupServ).
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
fn group_drop(&mut self, name: &str) -> Result<(), ChanError>;
fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError>;
fn group_del_member(&mut self, name: &str, account: &str) -> Result<bool, ChanError>;
fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
fn group(&self, name: &str) -> Option<GroupView>;
fn groups(&self) -> Vec<String>;
fn groups_of(&self, account: &str) -> Vec<String>;
// A user's effective channel capabilities (direct access unioned with groups).
fn channel_caps(&self, channel: &str, account: &str) -> Caps;
// Runtime operator grants (OperServ OPER), merged with config opers. `expires` // Runtime operator grants (OperServ OPER), merged with config opers. `expires`
// is an absolute unix time (None = permanent); opers_list hides expired ones. // is an absolute unix time (None = permanent); opers_list hides expired ones.
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>); fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>);
@ -1081,11 +1134,11 @@ mod tests {
#[test] #[test]
fn access_flags_apply_and_resolve() { fn access_flags_apply_and_resolve() {
// +/- deltas, dedup, stable order, invalid flag rejected. // +/- deltas, dedup, stable order, invalid flag rejected.
assert_eq!(apply_flags("", "+ov").unwrap(), "ov"); assert_eq!(apply_flags("", "+ov", ACCESS_FLAGS).unwrap(), "ov");
assert_eq!(apply_flags("ov", "-o").unwrap(), "v"); assert_eq!(apply_flags("ov", "-o", ACCESS_FLAGS).unwrap(), "v");
assert_eq!(apply_flags("v", "+tv").unwrap(), "vt"); // ACCESS_FLAGS order, no dup assert_eq!(apply_flags("v", "+tv", ACCESS_FLAGS).unwrap(), "vt"); // ACCESS_FLAGS order, no dup
assert_eq!(apply_flags("", "ai").unwrap(), "ia"); // bare letters grant assert_eq!(apply_flags("", "ai", ACCESS_FLAGS).unwrap(), "ia"); // bare letters grant
assert_eq!(apply_flags("", "+ox"), Err('x')); assert_eq!(apply_flags("", "+ox", ACCESS_FLAGS), Err('x'));
// Legacy presets and flag strings both resolve to capabilities. // Legacy presets and flag strings both resolve to capabilities.
assert_eq!(level_caps("op").auto, Some("+o")); assert_eq!(level_caps("op").auto, Some("+o"));

View file

@ -49,7 +49,7 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv
return; return;
} }
let base = current.unwrap_or_default(); let base = current.unwrap_or_default();
let updated = match apply_flags(&base, delta) { let updated = match apply_flags(&base, delta, ACCESS_FLAGS) {
Ok(f) => f, Ok(f) => f,
Err(bad) => { Err(bad) => {
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02.")); ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02."));

8
groupserv/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-groupserv"
version = "0.0.1"
edition = "2021"
description = "GroupServ: user groups (!name) whose members can be granted channel access together."
[dependencies]
fedserv-api = { path = "../api" }

226
groupserv/src/lib.rs Normal file
View file

@ -0,0 +1,226 @@
//! GroupServ manages user groups — a `!name` owning a set of member accounts.
//! A group's real purpose is the interconnection: a channel can grant access to
//! a `!group` (via ChanServ FLAGS/ACCESS), and every member then inherits that
//! channel access. Members carry group-access flags (the same flag primitive
//! ChanServ uses): F founder, f manage members, i invite, c channel-access,
//! s set, m memo. Managing members needs the founder or the `f` flag.
use fedserv_api::{apply_flags, NetView, Sender, Service, ServiceCtx, Store, GROUP_FLAGS};
pub struct GroupServ {
pub uid: String,
}
impl Service for GroupServ {
fn nick(&self) -> &str {
"GroupServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Group Service"
}
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) {
let me = self.uid.as_str();
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("REGISTER") => register(me, from, args.get(1).copied(), ctx, db),
Some("DROP") => drop(me, from, args.get(1).copied(), ctx, db),
Some("INFO") => info(me, from, args.get(1).copied(), ctx, db),
Some("LIST") => list(me, from, ctx, db),
Some("ADD") => add(me, from, args.get(1).copied(), args.get(2).copied(), ctx, db),
Some("DEL") => del(me, from, args.get(1).copied(), args.get(2).copied(), ctx, db),
Some("FLAGS") => flags(me, from, args.get(1).copied(), args.get(2).copied(), args.get(3).copied(), ctx, db),
Some("HELP") | None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "GroupServ manages user groups. \x02REGISTER\x02 <!group>, \x02DROP\x02 <!group>, \x02INFO\x02 <!group>, \x02LIST\x02, \x02ADD\x02/\x02DEL\x02 <!group> <account>, \x02FLAGS\x02 <!group> [account [+/-flags]]. Grant a group channel access with ChanServ \x02FLAGS #chan !group +o\x02 — every member then inherits it.");
}
// The caller must be logged in; returns their account.
fn account<'a>(me: &str, from: &'a Sender, ctx: &mut ServiceCtx) -> Option<&'a str> {
match from.account {
Some(a) => Some(a),
None => {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
None
}
}
}
// Whether `who` may manage `group` (founder, or holds the F/f flag).
fn can_manage(group: &fedserv_api::GroupView, who: &str) -> bool {
group.founder.eq_ignore_ascii_case(who)
|| group.members.iter().any(|m| m.account.eq_ignore_ascii_case(who) && (m.flags.contains('F') || m.flags.contains('f')))
}
fn register(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: REGISTER <!group>");
return;
};
if !name.starts_with('!') || name.len() < 2 {
ctx.notice(me, from.uid, "A group name starts with \x02!\x02, e.g. \x02!staff\x02.");
return;
}
let acc = acc.to_string();
match db.group_register(name, &acc) {
Ok(()) => ctx.notice(me, from.uid, format!("Group \x02{name}\x02 registered — you're the founder.")),
Err(fedserv_api::ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{name}\x02 is already registered.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn drop(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: DROP <!group>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !g.founder.eq_ignore_ascii_case(acc) {
ctx.notice(me, from.uid, format!("Only \x02{name}\x02's founder can drop it."));
return;
}
match db.group_drop(name) {
Ok(()) => ctx.notice(me, from.uid, format!("Group \x02{name}\x02 has been dropped.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn info(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: INFO <!group>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", g.name));
ctx.notice(me, from.uid, format!(" Founder : \x02{}\x02", g.founder));
ctx.notice(me, from.uid, format!(" Members : {}", g.members.len()));
}
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
// Operators see every group; others see the ones they belong to.
let names = if from.privs.any() {
db.groups()
} else if let Some(acc) = from.account {
db.groups_of(acc)
} else {
Vec::new()
};
if names.is_empty() {
ctx.notice(me, from.uid, "No groups to show.");
return;
}
for n in &names {
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
}
ctx.notice(me, from.uid, format!("End of list ({} group(s)).", names.len()));
}
fn add(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let (Some(name), Some(target)) = (name, target) else {
ctx.notice(me, from.uid, "Syntax: ADD <!group> <account>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !can_manage(&g, acc) {
ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to manage \x02{name}\x02."));
return;
}
let Some(canonical) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
match db.group_set_flags(name, &canonical, "") {
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{canonical}\x02 to \x02{name}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn del(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let (Some(name), Some(target)) = (name, target) else {
ctx.notice(me, from.uid, "Syntax: DEL <!group> <account>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !can_manage(&g, acc) {
ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to manage \x02{name}\x02."));
return;
}
match db.group_del_member(name, target) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{target}\x02 from \x02{name}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't in \x02{name}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn flags(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, delta: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: FLAGS <!group> [account [+/-flags]]");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
// List.
let Some(target) = target else {
ctx.notice(me, from.uid, format!("Flags for \x02{}\x02:", g.name));
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder): \x02F\x02", g.founder));
for m in &g.members {
ctx.notice(me, from.uid, format!(" \x02{}\x02: \x02{}\x02", m.account, if m.flags.is_empty() { "(member)" } else { &m.flags }));
}
return;
};
let current = g.members.iter().find(|m| m.account.eq_ignore_ascii_case(target)).map(|m| m.flags.clone());
// Show one.
let Some(delta) = delta else {
match current {
Some(f) => ctx.notice(me, from.uid, format!("\x02{target}\x02 in \x02{name}\x02: \x02{}\x02", if f.is_empty() { "(member)" } else { &f })),
None => ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't in \x02{name}\x02.")),
}
return;
};
// Change — needs founder or f flag.
if !can_manage(&g, acc) {
ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to change \x02{name}\x02."));
return;
}
let Some(canonical) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
let updated = match apply_flags(current.as_deref().unwrap_or(""), delta, GROUP_FLAGS) {
Ok(f) => f,
Err(bad) => {
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid group flag. Valid: \x02{GROUP_FLAGS}\x02."));
return;
}
};
match db.group_set_flags(name, &canonical, &updated) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{canonical}\x02 in \x02{name}\x02 now holds \x02{}\x02.", if updated.is_empty() { "(member)" } else { &updated })),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -30,7 +30,7 @@ use super::scram::{self, Hash};
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected. // modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
pub use fedserv_api::{ pub use fedserv_api::{
AccountView, AjoinView, AkillView, BotView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, AccountView, AjoinView, AkillView, BotView, Caps, GroupView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
}; };
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -192,6 +192,14 @@ pub enum Event {
ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 }, ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 },
ReportClosed { id: u64 }, ReportClosed { id: u64 },
ReportDeleted { id: u64 }, ReportDeleted { id: u64 },
// User groups (GroupServ). Global — groups are account identity.
GroupRegistered { name: String, founder: String, ts: u64 },
GroupDropped { name: String },
GroupFounderSet { name: String, founder: String },
// Upsert a member (empty flags = a plain member, still present).
GroupFlagsSet { name: String, account: String, flags: String },
// Remove a member from a group entirely.
GroupMemberDel { name: String, account: String },
// Runtime operator grants (OperServ OPER). // Runtime operator grants (OperServ OPER).
OperGranted { OperGranted {
account: String, account: String,
@ -267,6 +275,11 @@ impl Event {
| Event::ReportFiled { .. } | Event::ReportFiled { .. }
| Event::ReportClosed { .. } | Event::ReportClosed { .. }
| Event::ReportDeleted { .. } | Event::ReportDeleted { .. }
| Event::GroupRegistered { .. }
| Event::GroupDropped { .. }
| Event::GroupFounderSet { .. }
| Event::GroupFlagsSet { .. }
| Event::GroupMemberDel { .. }
| Event::OperGranted { .. } | Event::OperGranted { .. }
| Event::OperRevoked { .. } | Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. } | Event::SessionExceptionAdded { .. }
@ -382,6 +395,22 @@ pub struct News {
pub ts: u64, pub ts: u64,
} }
// A user group (GroupServ): a `!name`, its founder account, and member accounts
// each with group-access flags. Groups can be granted channel access.
#[derive(Debug, Clone)]
pub struct Group {
pub name: String, // "!name", casefolded
pub founder: String,
pub ts: u64,
pub members: Vec<GroupMember>,
}
#[derive(Debug, Clone)]
pub struct GroupMember {
pub account: String,
pub flags: String,
}
// An abuse report (ReportServ): who filed it, the nick/channel it's about, why, // An abuse report (ReportServ): who filed it, the nick/channel it's about, why,
// when, and whether it's still open. Stable id like News. // when, and whether it's still open. Stable id like News.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -405,6 +434,8 @@ pub struct NetData {
// Abuse reports (ReportServ), oldest first. // Abuse reports (ReportServ), oldest first.
pub reports: Vec<Report>, pub reports: Vec<Report>,
pub report_seq: u64, pub report_seq: u64,
// User groups (GroupServ).
pub groups: Vec<Group>,
// Runtime services operators (OperServ OPER), casefolded account -> grant. // Runtime services operators (OperServ OPER), casefolded account -> grant.
// Merged with the declarative [[oper]] config at the engine. // Merged with the declarative [[oper]] config at the engine.
pub opers: HashMap<String, OperGrant>, pub opers: HashMap<String, OperGrant>,
@ -1179,6 +1210,12 @@ impl Db {
snapshot.push(Event::ReportClosed { id: r.id }); snapshot.push(Event::ReportClosed { id: r.id });
} }
} }
for g in &self.net.groups {
snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts });
for m in &g.members {
snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() });
}
}
for (account, grant) in &self.net.opers { for (account, grant) in &self.net.opers {
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires }); snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires });
} }
@ -2236,6 +2273,127 @@ impl Db {
self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open }) self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open })
} }
/// Register a new group (name must start with `!`). Founder is an account.
pub fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
if !name.starts_with('!') || name.len() < 2 {
return Err(ChanError::InvalidPattern);
}
if self.group(name).is_some() {
return Err(ChanError::Exists);
}
let ts = now();
self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts }).map_err(|_| ChanError::Internal)?;
self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, members: Vec::new() });
Ok(())
}
/// Drop a group.
pub fn group_drop(&mut self, name: &str) -> Result<(), ChanError> {
if self.group(name).is_none() {
return Err(ChanError::NoChannel);
}
self.log.append(Event::GroupDropped { name: name.to_string() }).map_err(|_| ChanError::Internal)?;
let k = key(name);
self.net.groups.retain(|g| key(&g.name) != k);
Ok(())
}
/// Upsert a group member with `flags` (empty = a plain member).
pub fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError> {
let k = key(name);
let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else {
return Err(ChanError::NoChannel);
};
self.log.append(Event::GroupFlagsSet { name: name.to_string(), account: account.to_string(), flags: flags.to_string() }).map_err(|_| ChanError::Internal)?;
g.members.retain(|m| !m.account.eq_ignore_ascii_case(account));
g.members.push(GroupMember { account: account.to_string(), flags: flags.to_string() });
Ok(())
}
/// Remove a member from a group. Returns whether one was present.
pub fn group_del_member(&mut self, name: &str, account: &str) -> Result<bool, ChanError> {
let k = key(name);
let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else {
return Err(ChanError::NoChannel);
};
if !g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account)) {
return Ok(false);
}
self.log.append(Event::GroupMemberDel { name: name.to_string(), account: account.to_string() }).map_err(|_| ChanError::Internal)?;
g.members.retain(|m| !m.account.eq_ignore_ascii_case(account));
Ok(true)
}
/// Transfer a group's founder to another account.
pub fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
let k = key(name);
let Some(g) = self.net.groups.iter_mut().find(|g| key(&g.name) == k) else {
return Err(ChanError::NoChannel);
};
self.log.append(Event::GroupFounderSet { name: name.to_string(), founder: founder.to_string() }).map_err(|_| ChanError::Internal)?;
g.founder = founder.to_string();
Ok(())
}
/// A group view (founder + members), if it exists.
pub fn group(&self, name: &str) -> Option<GroupView> {
let k = key(name);
self.net.groups.iter().find(|g| key(&g.name) == k).map(|g| GroupView {
name: g.name.clone(),
founder: g.founder.clone(),
members: g.members.iter().map(|m| fedserv_api::GroupMemberView { account: m.account.clone(), flags: m.flags.clone() }).collect(),
})
}
/// Every group's name, sorted.
pub fn groups(&self) -> Vec<String> {
let mut names: Vec<String> = self.net.groups.iter().map(|g| g.name.clone()).collect();
names.sort();
names
}
/// The groups an account belongs to (founder or member).
pub fn groups_of(&self, account: &str) -> Vec<String> {
self.net
.groups
.iter()
.filter(|g| g.founder.eq_ignore_ascii_case(account) || g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account)))
.map(|g| g.name.clone())
.collect()
}
/// Whether an account is in a group (its founder or a member).
pub fn is_group_member(&self, name: &str, account: &str) -> bool {
let k = key(name);
self.net.groups.iter().find(|g| key(&g.name) == k).is_some_and(|g| g.founder.eq_ignore_ascii_case(account) || g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account)))
}
/// An account's effective channel capabilities, combining its direct access
/// entry with any `!group` access entry it belongs to (the interconnection
/// that lets a channel grant access to a whole group).
pub fn channel_caps(&self, channel: &str, account: &str) -> Caps {
let Some(c) = self.channels.get(&key(channel)) else { return Caps::default() };
if c.founder.eq_ignore_ascii_case(account) {
return fedserv_api::level_caps("founder");
}
let mut caps = Caps::default();
for a in &c.access {
let applies = match a.account.strip_prefix('!') {
Some(g) => self.is_group_member(&format!("!{g}"), account),
None => a.account.eq_ignore_ascii_case(account),
};
if applies {
caps = caps.union(fedserv_api::level_caps(&a.level));
}
}
caps
}
/// A user's join status mode for a channel, group-aware.
pub fn channel_join_mode(&self, channel: &str, account: &str) -> Option<&'static str> {
self.channel_caps(channel, account).auto
}
/// Add a services ignore, replacing any existing entry for the same mask. /// Add a services ignore, replacing any existing entry for the same mask.
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) { pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask)); self.ignores.retain(|i| !i.mask.eq_ignore_ascii_case(mask));
@ -3228,6 +3386,35 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
Event::ReportDeleted { id } => { Event::ReportDeleted { id } => {
net.reports.retain(|r| r.id != id); net.reports.retain(|r| r.id != id);
} }
Event::GroupRegistered { name, founder, ts } => {
let k = key(&name);
if !net.groups.iter().any(|g| key(&g.name) == k) {
net.groups.push(Group { name, founder, ts, members: Vec::new() });
}
}
Event::GroupDropped { name } => {
let k = key(&name);
net.groups.retain(|g| key(&g.name) != k);
}
Event::GroupFounderSet { name, founder } => {
let k = key(&name);
if let Some(g) = net.groups.iter_mut().find(|g| key(&g.name) == k) {
g.founder = founder;
}
}
Event::GroupFlagsSet { name, account, flags } => {
let k = key(&name);
if let Some(g) = net.groups.iter_mut().find(|g| key(&g.name) == k) {
g.members.retain(|m| !m.account.eq_ignore_ascii_case(&account));
g.members.push(GroupMember { account, flags });
}
}
Event::GroupMemberDel { name, account } => {
let k = key(&name);
if let Some(g) = net.groups.iter_mut().find(|g| key(&g.name) == k) {
g.members.retain(|m| !m.account.eq_ignore_ascii_case(&account));
}
}
Event::OperGranted { account, privs, expires } => { Event::OperGranted { account, privs, expires } => {
net.opers.insert(key(&account), OperGrant { privs, expires }); net.opers.insert(key(&account), OperGrant { privs, expires });
} }
@ -3551,6 +3738,33 @@ impl Store for Db {
fn report(&self, id: u64) -> Option<ReportView> { fn report(&self, id: u64) -> Option<ReportView> {
Db::report(self, id) Db::report(self, id)
} }
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
Db::group_register(self, name, founder)
}
fn group_drop(&mut self, name: &str) -> Result<(), ChanError> {
Db::group_drop(self, name)
}
fn group_set_flags(&mut self, name: &str, account: &str, flags: &str) -> Result<(), ChanError> {
Db::group_set_flags(self, name, account, flags)
}
fn group_del_member(&mut self, name: &str, account: &str) -> Result<bool, ChanError> {
Db::group_del_member(self, name, account)
}
fn group_set_founder(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
Db::group_set_founder(self, name, founder)
}
fn group(&self, name: &str) -> Option<GroupView> {
Db::group(self, name)
}
fn groups(&self) -> Vec<String> {
Db::groups(self)
}
fn groups_of(&self, account: &str) -> Vec<String> {
Db::groups_of(self, account)
}
fn channel_caps(&self, channel: &str, account: &str) -> Caps {
Db::channel_caps(self, channel, account)
}
fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>) { fn oper_grant(&mut self, account: &str, privs: Vec<String>, expires: Option<u64>) {
Db::oper_grant(self, account, privs, expires) Db::oper_grant(self, account, privs, expires)
} }

View file

@ -816,7 +816,9 @@ impl Engine {
} }
let account = self.network.account_of(&uid).map(str::to_string); let account = self.network.account_of(&uid).map(str::to_string);
let from = self.chan_service.clone().unwrap_or_default(); let from = self.chan_service.clone().unwrap_or_default();
let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a))); // Group-aware: a member of a group that holds channel access gets
// the group's status mode too.
let mode = account.as_deref().and_then(|a| self.db.channel_join_mode(&channel, a));
let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty()); let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty());
// Computed before the match below moves `channel` into its actions. // Computed before the match below moves `channel` into its actions.
let greet = self.greet_on_join(&channel, account.as_deref()); let greet = self.greet_on_join(&channel, account.as_deref());
@ -1699,6 +1701,17 @@ fn audit_summary(event: &db::Event) -> Option<String> {
ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"), ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"),
ReportClosed { id } => format!("closed report #{id}"), ReportClosed { id } => format!("closed report #{id}"),
ReportDeleted { id } => format!("deleted report #{id}"), ReportDeleted { id } => format!("deleted report #{id}"),
GroupRegistered { name, founder, .. } => format!("registered group \x02{name}\x02 (founder \x02{founder}\x02)"),
GroupDropped { name } => format!("dropped group \x02{name}\x02"),
GroupFounderSet { name, founder } => format!("set founder of \x02{name}\x02 to \x02{founder}\x02"),
GroupFlagsSet { name, account, flags } => {
if flags.is_empty() {
format!("added \x02{account}\x02 to group \x02{name}\x02")
} else {
format!("set \x02{account}\x02 in group \x02{name}\x02 to \x02{flags}\x02")
}
}
GroupMemberDel { name, account } => format!("removed \x02{account}\x02 from group \x02{name}\x02"),
OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")), OperGranted { account, privs, .. } => format!("granted \x02{account}\x02 operator ({})", privs.join(", ")),
OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"), OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"),
SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"), SessionExceptionAdded { mask, limit, .. } => format!("set a session exception \x02{mask}\x02 (limit {limit})"),
@ -4831,6 +4844,57 @@ mod tests {
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}");
} }
// GroupServ interconnection: a channel grants access to a !group, and every
// group member inherits that channel access (auto-op on join).
#[test]
fn groupserv_grants_channel_access() {
use fedserv_chanserv::ChanServ;
use fedserv_groupserv::GroupServ;
let path = std::env::temp_dir().join("fedserv-groupserv.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
for a in ["alice", "bob", "carol"] {
db.register(a, "password1", None).unwrap();
}
db.register_channel("#room", "alice").unwrap();
let mut e = Engine::new(
vec![
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
Box::new(ChanServ { uid: "42SAAAAAB".into() }),
Box::new(GroupServ { uid: "42SAAAAAL".into() }),
],
db,
);
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
let gs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAL".into(), text: t.into() });
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
let opped = |out: &[NetAction], who: &str| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == &format!("+o {who}")));
for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "bob"), ("000AAAAAC", "carol")] {
e.handle(NetEvent::UserConnect { uid: uid.into(), nick: nick.into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
}
// alice makes a group, adds bob, and grants the group op access on #room.
assert!(has(&gs(&mut e, "000AAAAAA", "REGISTER !team"), "registered"), "group registered");
assert!(has(&gs(&mut e, "000AAAAAA", "ADD !team bob"), "Added"), "bob added");
assert!(has(&cs(&mut e, "000AAAAAA", "FLAGS #room !team +o"), "now holds"), "group granted channel op");
// bob, a group member, is auto-opped on join; carol (not in the group) isn't.
assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }), "000AAAAAB"), "group member auto-opped");
assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#room".into(), op: false }), "000AAAAAC"), "non-member not opped");
// Removing bob from the group revokes his inherited channel access.
gs(&mut e, "000AAAAAA", "DEL !team bob");
assert_eq!(e.db.channel_join_mode("#room", "bob"), None, "access gone once out of the group");
// Managing the group needs the founder or the f flag.
assert!(has(&gs(&mut e, "000AAAAAC", "ADD !team carol"), "founder or the \x02f\x02 flag"), "outsider can't manage");
gs(&mut e, "000AAAAAA", "FLAGS !team carol +f");
assert!(has(&gs(&mut e, "000AAAAAC", "ADD !team bob"), "Added"), "carol with f can now manage");
}
// ChanServ FLAGS: granular access grants flow through the shared resolver, so // ChanServ FLAGS: granular access grants flow through the shared resolver, so
// a flag-granted +o gets auto-op like a legacy op entry; changing needs the // a flag-granted +o gets auto-op like a legacy op entry; changing needs the
// founder or the 'a' flag. // founder or the 'a' flag.

View file

@ -176,6 +176,11 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::ReportFiled { .. } | Event::ReportFiled { .. }
| Event::ReportClosed { .. } | Event::ReportClosed { .. }
| Event::ReportDeleted { .. } | Event::ReportDeleted { .. }
| Event::GroupRegistered { .. }
| Event::GroupDropped { .. }
| Event::GroupFounderSet { .. }
| Event::GroupFlagsSet { .. }
| Event::GroupMemberDel { .. }
| Event::OperGranted { .. } | Event::OperGranted { .. }
| Event::OperRevoked { .. } | Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. } | Event::SessionExceptionAdded { .. }

View file

@ -24,6 +24,7 @@ use fedserv_operserv::OperServ;
use fedserv_diceserv::DiceServ; use fedserv_diceserv::DiceServ;
use fedserv_infoserv::InfoServ; use fedserv_infoserv::InfoServ;
use fedserv_reportserv::ReportServ; use fedserv_reportserv::ReportServ;
use fedserv_groupserv::GroupServ;
use fedserv_example::ExampleServ; use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd; use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ; use fedserv_nickserv::NickServ;
@ -107,6 +108,11 @@ async fn main() -> Result<()> {
uid: format!("{}AAAAAK", cfg.server.sid), uid: format!("{}AAAAAK", cfg.server.sid),
})); }));
} }
if enabled("groupserv") {
services.push(Box::new(GroupServ {
uid: format!("{}AAAAAL", cfg.server.sid),
}));
}
if enabled("example") { if enabled("example") {
services.push(Box::new(ExampleServ { services.push(Box::new(ExampleServ {
uid: format!("{}AAAAAC", cfg.server.sid), uid: format!("{}AAAAAC", cfg.server.sid),