chanserv: LEVELS grants a capability (op/topic/invite/access) to an access tier and above, additive so it never locks anyone out
This commit is contained in:
parent
0c3e09ea00
commit
d313ca3523
10 changed files with 275 additions and 6 deletions
|
|
@ -11,7 +11,7 @@ impl Db {
|
|||
self.log
|
||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +111,40 @@ impl Db {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant a LEVELS capability to `role` (and above) on `channel`. `cap`/`role`
|
||||
/// are the canonical names (echo_api::LevelCap / AccessRole), validated by the
|
||||
/// command layer before this is called.
|
||||
pub fn level_set(&mut self, channel: &str, cap: &str, role: &str) -> Result<(), ChanError> {
|
||||
let k = key(channel);
|
||||
if !self.channels.contains_key(&k) {
|
||||
return Err(ChanError::NoChannel);
|
||||
}
|
||||
self.log
|
||||
.append(Event::ChannelLevelSet { channel: channel.to_string(), cap: cap.to_string(), role: role.to_string() })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
let c = self.channels.get_mut(&k).unwrap();
|
||||
c.levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(cap));
|
||||
c.levels.push((cap.to_string(), role.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset a LEVELS capability on `channel` to its tier default. Ok(false) if it
|
||||
/// had no override.
|
||||
pub fn level_reset(&mut self, channel: &str, cap: &str) -> Result<bool, ChanError> {
|
||||
let k = key(channel);
|
||||
let Some(c) = self.channels.get(&k) else {
|
||||
return Err(ChanError::NoChannel);
|
||||
};
|
||||
if !c.levels.iter().any(|(cp, _)| cp.eq_ignore_ascii_case(cap)) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.log
|
||||
.append(Event::ChannelLevelReset { channel: channel.to_string(), cap: cap.to_string() })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
self.channels.get_mut(&k).unwrap().levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(cap));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove auto-kick `mask` from `channel`. Ok(false) if not present.
|
||||
pub fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
||||
let k = key(channel);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ pub enum Event {
|
|||
ChannelAccessDel { channel: String, account: String },
|
||||
ChannelAkickAdd { channel: String, mask: String, reason: String },
|
||||
ChannelAkickDel { channel: String, mask: String },
|
||||
// LEVELS: grant `cap` to access tier `role` (and above), or reset it to default.
|
||||
ChannelLevelSet { channel: String, cap: String, role: String },
|
||||
ChannelLevelReset { channel: String, cap: String },
|
||||
ChannelFounderSet { channel: String, founder: String },
|
||||
ChannelSuccessorSet { channel: String, successor: Option<String> },
|
||||
ChannelDescSet { channel: String, desc: String },
|
||||
|
|
@ -226,6 +229,8 @@ impl Event {
|
|||
| Event::ChannelAccessDel { .. }
|
||||
| Event::ChannelAkickAdd { .. }
|
||||
| Event::ChannelAkickDel { .. }
|
||||
| Event::ChannelLevelSet { .. }
|
||||
| Event::ChannelLevelReset { .. }
|
||||
| Event::ChannelFounderSet { .. }
|
||||
| Event::ChannelSuccessorSet { .. }
|
||||
| Event::ChannelDescSet { .. }
|
||||
|
|
@ -448,7 +453,7 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
|||
grouped.remove(&key(&nick));
|
||||
}
|
||||
Event::ChannelRegistered { name, founder, ts } => {
|
||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||
}
|
||||
Event::ChannelDropped { name } => {
|
||||
channels.remove(&key(&name));
|
||||
|
|
@ -482,6 +487,17 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
|||
c.akick.retain(|k| !k.mask.eq_ignore_ascii_case(&mask));
|
||||
}
|
||||
}
|
||||
Event::ChannelLevelSet { channel, cap, role } => {
|
||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||
c.levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(&cap));
|
||||
c.levels.push((cap, role));
|
||||
}
|
||||
}
|
||||
Event::ChannelLevelReset { channel, cap } => {
|
||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||
c.levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(&cap));
|
||||
}
|
||||
}
|
||||
Event::ChannelFounderSet { channel, founder } => {
|
||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||
c.founder = founder;
|
||||
|
|
|
|||
|
|
@ -439,6 +439,10 @@ pub struct ChannelInfo {
|
|||
pub access: Vec<ChanAccess>,
|
||||
#[serde(default)]
|
||||
pub akick: Vec<ChanAkick>,
|
||||
// LEVELS overrides as (capability, tier) name pairs — a capability granted to
|
||||
// that access tier and above. Strings at the persistence seam; typed in the view.
|
||||
#[serde(default)]
|
||||
pub levels: Vec<(String, String)>,
|
||||
// Account that inherits the channel if the founder's account is dropped or
|
||||
// expires; None means the channel is released instead.
|
||||
#[serde(default)]
|
||||
|
|
|
|||
|
|
@ -575,6 +575,12 @@ impl Store for Db {
|
|||
fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
||||
Db::akick_del(self, channel, mask)
|
||||
}
|
||||
fn level_set(&mut self, channel: &str, cap: &str, role: &str) -> Result<(), ChanError> {
|
||||
Db::level_set(self, channel, cap, role)
|
||||
}
|
||||
fn level_reset(&mut self, channel: &str, cap: &str) -> Result<bool, ChanError> {
|
||||
Db::level_reset(self, channel, cap)
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||
|
|
@ -587,6 +593,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
|||
lock_params: c.lock_params.clone(),
|
||||
access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(),
|
||||
akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(),
|
||||
levels: c.levels.iter().filter_map(|(cap, role)| Some((echo_api::LevelCap::parse(cap)?, echo_api::AccessRole::parse(role)?))).collect(),
|
||||
desc: c.desc.clone(),
|
||||
entrymsg: c.entrymsg.clone(),
|
||||
url: c.url.clone(),
|
||||
|
|
|
|||
|
|
@ -106,6 +106,30 @@
|
|||
assert!(!db.akick_del("#c", "*!*@bad.host").unwrap());
|
||||
}
|
||||
|
||||
// LEVELS grants are additive (default = unchanged), tier-gated, and reversible.
|
||||
#[test]
|
||||
fn levels_grant_is_additive_and_tier_gated() {
|
||||
let mut db = Db::open(tmp("levels"), "N1");
|
||||
db.register_channel("#c", "founder").unwrap();
|
||||
db.access_add("#c", "aop", "op").unwrap();
|
||||
db.access_add("#c", "vop", "voice").unwrap();
|
||||
// caps_of lives on the ChannelView the Store builds (levels are typed there).
|
||||
let view = |db: &Db| echo_api::Store::channel(db, "#c").unwrap();
|
||||
// Baseline: no LEVELS set → an AOP can op but can't manage the access list.
|
||||
assert!(view(&db).levels.is_empty(), "no overrides by default");
|
||||
assert!(view(&db).caps_of(Some("aop")).op);
|
||||
assert!(!view(&db).caps_of(Some("aop")).access, "AOP lacks access mgmt by default");
|
||||
// Grant ACCESS to AOP-and-above.
|
||||
db.level_set("#c", "ACCESS", "AOP").unwrap();
|
||||
assert!(view(&db).caps_of(Some("aop")).access, "AOP now manages the access list");
|
||||
assert!(!view(&db).caps_of(Some("vop")).access, "VOP is below AOP — not granted");
|
||||
assert!(view(&db).caps_of(Some("aop")).op, "existing caps untouched");
|
||||
// Reset restores the default.
|
||||
assert!(db.level_reset("#c", "ACCESS").unwrap());
|
||||
assert!(!view(&db).caps_of(Some("aop")).access);
|
||||
assert!(!db.level_reset("#c", "ACCESS").unwrap(), "already default");
|
||||
}
|
||||
|
||||
// The auto-join list adds, updates a key in place, removes case-insensitively,
|
||||
// and replays from the event log after a reopen.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1640,6 +1640,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
|||
ChannelAccessDel { channel, account } => format!("removed \x02{account}\x02 from \x02{channel}\x02 access"),
|
||||
ChannelAkickAdd { channel, mask, reason } => format!("added akick \x02{mask}\x02 on \x02{channel}\x02 ({reason})"),
|
||||
ChannelAkickDel { channel, mask } => format!("removed akick \x02{mask}\x02 on \x02{channel}\x02"),
|
||||
ChannelLevelSet { channel, cap, role } => format!("set level \x02{cap}\x02 to \x02{role}\x02 on \x02{channel}\x02"),
|
||||
ChannelLevelReset { channel, cap } => format!("reset level \x02{cap}\x02 on \x02{channel}\x02"),
|
||||
ChannelSuspended { channel, reason, .. } => format!("suspended channel \x02{channel}\x02 ({reason})"),
|
||||
ChannelUnsuspended { channel } => format!("unsuspended channel \x02{channel}\x02"),
|
||||
ChannelBotAssigned { channel, bot } => format!("assigned bot \x02{bot}\x02 to \x02{channel}\x02"),
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
|||
| Event::ChannelAccessDel { .. }
|
||||
| Event::ChannelAkickAdd { .. }
|
||||
| Event::ChannelAkickDel { .. }
|
||||
| Event::ChannelLevelSet { .. }
|
||||
| Event::ChannelLevelReset { .. }
|
||||
| Event::ChannelEntryMsgSet { .. }
|
||||
| Event::ChannelUrlSet { .. }
|
||||
| Event::ChannelEmailSet { .. }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue