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:
parent
c7a4d5be27
commit
09be99785e
10 changed files with 602 additions and 17 deletions
216
src/engine/db.rs
216
src/engine/db.rs
|
|
@ -30,7 +30,7 @@ use super::scram::{self, Hash};
|
|||
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
||||
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
||||
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)]
|
||||
|
|
@ -192,6 +192,14 @@ pub enum Event {
|
|||
ReportFiled { id: u64, reporter: String, target: String, reason: String, ts: u64 },
|
||||
ReportClosed { 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).
|
||||
OperGranted {
|
||||
account: String,
|
||||
|
|
@ -267,6 +275,11 @@ impl Event {
|
|||
| Event::ReportFiled { .. }
|
||||
| Event::ReportClosed { .. }
|
||||
| Event::ReportDeleted { .. }
|
||||
| Event::GroupRegistered { .. }
|
||||
| Event::GroupDropped { .. }
|
||||
| Event::GroupFounderSet { .. }
|
||||
| Event::GroupFlagsSet { .. }
|
||||
| Event::GroupMemberDel { .. }
|
||||
| Event::OperGranted { .. }
|
||||
| Event::OperRevoked { .. }
|
||||
| Event::SessionExceptionAdded { .. }
|
||||
|
|
@ -382,6 +395,22 @@ pub struct News {
|
|||
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,
|
||||
// when, and whether it's still open. Stable id like News.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -405,6 +434,8 @@ pub struct NetData {
|
|||
// Abuse reports (ReportServ), oldest first.
|
||||
pub reports: Vec<Report>,
|
||||
pub report_seq: u64,
|
||||
// User groups (GroupServ).
|
||||
pub groups: Vec<Group>,
|
||||
// Runtime services operators (OperServ OPER), casefolded account -> grant.
|
||||
// Merged with the declarative [[oper]] config at the engine.
|
||||
pub opers: HashMap<String, OperGrant>,
|
||||
|
|
@ -1179,6 +1210,12 @@ impl Db {
|
|||
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 {
|
||||
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 })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
|
||||
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 } => {
|
||||
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 } => {
|
||||
net.opers.insert(key(&account), OperGrant { privs, expires });
|
||||
}
|
||||
|
|
@ -3551,6 +3738,33 @@ impl Store for Db {
|
|||
fn report(&self, id: u64) -> Option<ReportView> {
|
||||
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>) {
|
||||
Db::oper_grant(self, account, privs, expires)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -816,7 +816,9 @@ impl Engine {
|
|||
}
|
||||
let account = self.network.account_of(&uid).map(str::to_string);
|
||||
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());
|
||||
// Computed before the match below moves `channel` into its actions.
|
||||
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}"),
|
||||
ReportClosed { id } => format!("closed 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(", ")),
|
||||
OperRevoked { account } => format!("revoked \x02{account}\x02's operator access"),
|
||||
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:?}");
|
||||
}
|
||||
|
||||
// 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
|
||||
// a flag-granted +o gets auto-op like a legacy op entry; changing needs the
|
||||
// founder or the 'a' flag.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue